From 57f242deda47fec756846055d40bac15aa060426 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:02:19 +0000 Subject: [PATCH 1/2] fix(plugin-chatbot): render an answer's text and charts in stream order mapMessages joined every text part into one `content` and lifted every `data-chart` part into one trailing `charts[]`, so a chart the model drew before writing its analysis still rendered below all of it. Each chart now records where it arrived among the text parts (`textOffset`), and the bubble splits the answer at those points. A message without offsets keeps the old layout. Claude-Session: https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o Co-authored-by: Claude --- .changeset/13492-chat-chart-order.md | 8 + .../plugin-chatbot/src/ChatbotEnhanced.tsx | 191 +++++++++++++----- .../ChatbotEnhanced.chartOrder-13492.test.tsx | 175 ++++++++++++++++ .../mapMessages.chartOrder-13492.test.ts | 126 ++++++++++++ .../src/__tests__/mapMessages.test.ts | 2 + ...ectChat.chartOrderRehydrate-13492.test.tsx | 83 ++++++++ packages/plugin-chatbot/src/mapMessages.ts | 17 +- 7 files changed, 547 insertions(+), 55 deletions(-) create mode 100644 .changeset/13492-chat-chart-order.md create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.chartOrder-13492.test.tsx create mode 100644 packages/plugin-chatbot/src/__tests__/mapMessages.chartOrder-13492.test.ts create mode 100644 packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx diff --git a/.changeset/13492-chat-chart-order.md b/.changeset/13492-chat-chart-order.md new file mode 100644 index 0000000000..8632e18882 --- /dev/null +++ b/.changeset/13492-chat-chart-order.md @@ -0,0 +1,8 @@ +--- +"@object-ui/plugin-chatbot": patch +--- + +Clause-②: no +Metadata-protocol: none + +An assistant answer now renders its text and its charts in the order the stream produced them. A chart the model drew before writing its analysis shows first, instead of below every paragraph of it, so an open-ended analysis in the assistant panel puts its chart on the first screen. `mapMessages` records where each `data-chart` part arrived among the text parts as the chart's new optional `textOffset`, and `` splits the answer at those points. A message without offsets (one a host builds itself, or the legacy string-content shape) keeps the old layout: all the text, then the charts. `content` and `charts` keep their shape; long-answer folding and record links work in every text run. diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 21d1475e9b..8ec8590a5c 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -154,8 +154,9 @@ export interface ChatbotEnhancedMessage { /** * A chart lifted from a `data-chart` stream part. Mirrors the `schema` prop of - * the SDUI `` renderer (plugin-charts `ChartRenderer`), so it can be - * fed straight into ``. + * the SDUI `` renderer (plugin-charts `ChartRenderer`), so its chart + * keys can be fed straight into ``; + * `textOffset` is the one key that places the chart in the bubble instead. */ export interface ChatChart { chartType?: @@ -176,6 +177,16 @@ export interface ChatChart { xAxisKey?: string; /** One entry per plotted measure. `dataKey` is the row column to read. */ series: Array<{ dataKey: string; label?: string }>; + /** + * Where the chart sits in the answer: how many characters of the message's + * `content` the stream produced before it (objectui#13492). The bubble + * renders the text up to that point, then the chart, then the rest, so a + * chart the model drew before writing its analysis shows first. Absent, or + * past the end of `content`, the chart renders after all the text. + * `mapMessages` sets it from the part order; it is a placement key and never + * reaches the `` schema. + */ + textOffset?: number; } /** @@ -1711,6 +1722,89 @@ function isToolCallPlaceholder(content: string): boolean { return /^\((?:called [^)]*|tool call|no content)\)$/.test(content.trim()); } +/** One run of an assistant answer: a slice of its text, or the charts drawn at one point. */ +type AnswerBlock = + | { kind: 'text'; text: string } + | { kind: 'charts'; charts: ChatChart[] }; + +/** + * objectui#13492 — an assistant answer in the order the stream produced it: + * the text up to each chart's `textOffset`, the chart, then the text after. + * The mapper used to flatten every text part into one `content` and every + * `data-chart` part into one trailing `charts[]` block, so a chart the model + * drew first still landed below six paragraphs of analysis. + * + * A chart without an offset (a host-built message, the legacy string-content + * shape) or with one past the text's end trails all of it, which is exactly + * the old layout. Charts at the same offset share one block, and a slice that + * is only whitespace is dropped rather than painted as an empty answer box. + */ +function layoutAnswer(content: string, charts: ChatChart[] | undefined): AnswerBlock[] { + const placed = (charts ?? []) + .map((chart, order) => ({ + chart, + order, + at: + typeof chart.textOffset === 'number' && Number.isFinite(chart.textOffset) + ? Math.min(Math.max(chart.textOffset, 0), content.length) + : content.length, + })) + .sort((a, b) => a.at - b.at || a.order - b.order); + const blocks: AnswerBlock[] = []; + let cursor = 0; + for (const { chart, at } of placed) { + if (at > cursor) { + const text = content.slice(cursor, at); + if (text.trim().length > 0) blocks.push({ kind: 'text', text }); + cursor = at; + } + const last = blocks[blocks.length - 1]; + if (last?.kind === 'charts') last.charts.push(chart); + else blocks.push({ kind: 'charts', charts: [chart] }); + } + const rest = content.slice(cursor); + if (rest.trim().length > 0) blocks.push({ kind: 'text', text: rest }); + return blocks; +} + +/** The charts drawn at one point of an assistant answer, each through the SDUI ``. */ +function AnswerCharts({ charts }: { charts: ChatChart[] }) { + return ( +
+ {charts.map((chart, i) => ( +
+ {chart.title ? ( +
+ {chart.title} +
+ ) : null} + +
+ ))} +
+ ); +} + /** * The "1 object · 1 view · 1 dashboard" strip under a proposed plan / a live * design panel, localized (objectui#7254). @@ -4078,6 +4172,14 @@ const ChatbotEnhanced = React.forwardRef( detailedTools.length > 0 || Boolean(buildProgress) || Boolean(blueprintProgress); + // objectui#13492 — the answer body in stream order; the + // streaming cursor rides the last text run, where it sat when + // the charts always trailed the text. + const answerBlocks = isUser ? undefined : layoutAnswer(message.content, message.charts); + let lastTextBlock = -1; + answerBlocks?.forEach((block, i) => { + if (block.kind === 'text') lastTextBlock = i; + }); return (
( ) : null ) : isEmptyAssistantStreaming ? ( - ) : hasVisibleProse ? ( - // objectui#13032 — a long answer folds only once its - // stream has ended; the streaming flag tells it when. - // objectui#13037 — a record link in the answer opens - // the record through the same host opener as the - // `open_record` card. - - - {message.content} - - - ) : !message.streaming && !showsActivity && isToolCallPlaceholder(message.content) ? ( + ) : !hasVisibleProse && + !message.streaming && + !showsActivity && + isToolCallPlaceholder(message.content) ? ( // #772: a re-hydrated tool-call-only turn is persisted with // an internal placeholder ("(called todo_write, // propose_blueprint)"); render a quiet localized activity @@ -4194,45 +4288,36 @@ const ChatbotEnhanced = React.forwardRef( {L.agentActivity} ) : null} - {message.streaming && hasVisibleProse ? ( - - ) : null} - {!isUser && (message.charts?.length ?? 0) > 0 ? ( -
- {message.charts!.map((chart, i) => ( -
- {chart.title ? ( -
- {chart.title} -
- ) : null} - -
- ))} -
+ {answerBlocks ? ( + // objectui#13492 — the answer's text and charts in + // stream order (see `layoutAnswer`). A turn with no + // showable prose still draws its charts here, so a + // chart drawn before the first word keeps its place + // (and its mounted chart) once the text arrives. + // objectui#13032 — a long answer folds only once its + // stream has ended; the streaming flag tells it when. + // objectui#13037 — a record link in the answer opens + // the record through the same host opener as the + // `open_record` card. + + {answerBlocks.map((block, i) => + block.kind === 'charts' ? ( + + ) : hasVisibleProse ? ( + + + {block.text} + + {message.streaming && i === lastTextBlock ? ( + + ) : null} + + ) : null, + )} + ) : null} {!isUser && sources.length > 0 ? ( diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.chartOrder-13492.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.chartOrder-13492.test.tsx new file mode 100644 index 0000000000..4d04aaf9e6 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.chartOrder-13492.test.tsx @@ -0,0 +1,175 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#13492 — an assistant answer renders its text and charts in the + * order the stream produced them. + * + * Measured on the card: in the console's assistant panel (627px viewport) the + * only chart of 「帮我分析一下客户」 started 785px down, under six paragraphs, + * although the model drew it first — the bubble rendered `content`, then every + * chart. The mapper's half (each chart's `textOffset`) is pinned in + * `mapMessages.chartOrder-13492.test.ts`; this file feeds the bubble messages + * mapped from real part sequences and reads the DOM order. + * + * happy-dom lays nothing out, so "first screen" is read as document order, and + * the fold (objectui#13032) is driven through a stubbed `scrollHeight`, the way + * `chatAnswerLayout-13032.test.tsx` drives it. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; +import type { ChatbotEnhancedMessage } from '../ChatbotEnhanced'; +import { uiMessageToChatMessage } from '../mapMessages'; + +const user = { id: 'u1', role: 'user', content: 'analyse my customers' } as ChatbotEnhancedMessage; + +const chartPart = (id: string, title: string) => ({ + type: 'data-chart', + id, + data: { + type: 'chart', + chartType: 'pie', + title, + xAxisKey: 'segment', + series: [{ dataKey: 'count' }], + data: [{ segment: 'healthy', count: 12 }], + }, +}); +const text = (t: string) => ({ type: 'text', text: t }); + +function answer(parts: Array>, streaming = false): ChatbotEnhancedMessage { + return { ...uiMessageToChatMessage({ id: 'a1', role: 'assistant', parts }), streaming }; +} + +function renderChat(message: ChatbotEnhancedMessage, extra: Partial> = {}) { + return render(); +} + +/** True when `a` comes before `b` in document order. */ +function precedes(a: Element, b: Element): boolean { + return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); +} + +const answers = (root: ParentNode) => Array.from(root.querySelectorAll('[data-chat-answer]')); +const charts = (root: ParentNode) => Array.from(root.querySelectorAll('[data-testid="chat-chart"]')); +/** The streaming caret the bubble draws after the text. */ +const carets = (root: ParentNode) => + Array.from(root.querySelectorAll('span[aria-hidden]')).filter((el) => el.className.includes('w-[2px]')); + +const restore: Array<() => void> = []; +afterEach(() => { + while (restore.length) restore.pop()!(); +}); + +describe('assistant text and charts render in stream order (objectui#13492)', () => { + it('data-chart then text: the chart renders before the analysis', async () => { + const { container } = renderChat(answer([chartPart('c0', 'Customer health'), text('Most customers are healthy.')])); + await screen.findByText('Most customers are healthy.'); + const [chart] = charts(container); + const [prose] = answers(container); + expect(chart).toHaveTextContent('Customer health'); + expect(precedes(chart, prose)).toBe(true); + }); + + it('text, data-chart, text: the three render in that order', async () => { + const { container } = renderChat( + answer([text('Here is the breakdown.'), chartPart('c0', 'Customer health'), text('Three accounts need attention.')]), + ); + await screen.findByText('Three accounts need attention.'); + const [before, after] = answers(container); + const [chart] = charts(container); + expect(answers(container)).toHaveLength(2); + expect(before).toHaveTextContent('Here is the breakdown.'); + expect(after).toHaveTextContent('Three accounts need attention.'); + expect(precedes(before, chart)).toBe(true); + expect(precedes(chart, after)).toBe(true); + }); + + it('CONTROL: a host-built message with no textOffset keeps the old layout, text then charts', async () => { + const message = { + id: 'a1', + role: 'assistant', + content: 'Here is the breakdown.', + charts: [{ chartType: 'bar', title: 'Customer health', data: [], series: [{ dataKey: 'count' }] }], + } as ChatbotEnhancedMessage; + const { container } = renderChat(message); + await screen.findByText('Here is the breakdown.'); + expect(answers(container)).toHaveLength(1); + expect(precedes(answers(container)[0], charts(container)[0])).toBe(true); + }); + + it('a chart drawn before the first word keeps its mounted node when the text arrives', async () => { + const { container, rerender } = renderChat(answer([chartPart('c0', 'Customer health')], true)); + const drawn = charts(container)[0]; + expect(drawn).toBeTruthy(); + rerender( + , + ); + await screen.findByText('Most customers are healthy.'); + expect(charts(container)[0]).toBe(drawn); + }); + + it('the streaming caret rides the last text run, not the chart', async () => { + const { container } = renderChat( + answer([text('Here is the breakdown.'), chartPart('c0', 'Customer health'), text('Three accounts')], true), + ); + await screen.findByText('Three accounts'); + const [caret] = carets(container); + expect(carets(container)).toHaveLength(1); + expect(precedes(answers(container)[1], caret)).toBe(true); + expect(precedes(charts(container)[0], caret)).toBe(true); + }); + + it('objectui#13037 does not regress: a record link in the text after a chart opens through the host opener', async () => { + const onOpenRecord = vi.fn(); + renderChat( + answer([chartPart('c0', 'Customer health'), text('Top customer: [Wayne Enterprises](/record/account/acc_42).')]), + { onOpenRecord }, + ); + const link = await screen.findByRole('button', { name: 'Wayne Enterprises' }); + await act(async () => { + fireEvent.click(link); + }); + await act(async () => {}); + expect(onOpenRecord).toHaveBeenCalledWith({ objectName: 'account', recordId: 'acc_42' }); + }); + + it('objectui#13032 does not regress: the long analysis after a chart still folds once the stream ends', async () => { + const original = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight') + ?? Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get(this: HTMLElement) { + // Only the long analysis is tall; the intro above the chart is not. + return this.hasAttribute('data-chat-answer-body') && this.textContent?.includes('Long analysis') ? 5966 : 0; + }, + }); + restore.push(() => { + if (original) Object.defineProperty(HTMLElement.prototype, 'scrollHeight', original); + else delete (HTMLElement.prototype as unknown as Record).scrollHeight; + }); + const parts = [text('Intro.'), chartPart('c0', 'Customer health'), text('Long analysis of every segment.')]; + const { container, rerender } = renderChat(answer(parts, true)); + await screen.findByText('Long analysis of every segment.'); + await act(async () => {}); + expect(answers(container).some((el) => el.hasAttribute('data-folded'))).toBe(false); + + rerender(); + await waitFor(() => expect(answers(container)[1]).toHaveAttribute('data-folded', 'true')); + expect(answers(container)[0]).not.toHaveAttribute('data-folded'); + // The chart above the fold stays outside it. + expect(answers(container)[1].contains(charts(container)[0])).toBe(false); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.chartOrder-13492.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.chartOrder-13492.test.ts new file mode 100644 index 0000000000..2835a7acf0 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.chartOrder-13492.test.ts @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#13492 — the mapper keeps WHERE each `data-chart` part arrived + * among the text parts. + * + * It joins every text part into one `content` and lifts every chart into one + * `charts[]`, and that flattening is what put a chart the model drew first + * below all of its analysis. Each chart now carries `textOffset`: the length of + * the joined text the stream produced before it. The bubble's half is pinned + * in `ChatbotEnhanced.chartOrder-13492.test.tsx`. + */ +import { describe, expect, it } from 'vitest'; +import { uiMessageToChatMessage, uiMessagesToChatMessages } from '../mapMessages'; + +const chartPart = (id: string, title: string) => ({ + type: 'data-chart', + id, + data: { + type: 'chart', + chartType: 'bar', + title, + xAxisKey: 'segment', + series: [{ dataKey: 'count' }], + data: [{ segment: 'healthy', count: 12 }], + }, +}); + +describe('data-chart parts keep their place among the text parts (objectui#13492)', () => { + it('a chart drawn before any text sits at offset 0', () => { + const out = uiMessageToChatMessage({ + id: 'a1', + role: 'assistant', + parts: [chartPart('c0', 'Customer health'), { type: 'text', text: 'Most customers are healthy.' }], + }); + expect(out.content).toBe('Most customers are healthy.'); + expect(out.charts?.map((c) => [c.title, c.textOffset])).toEqual([['Customer health', 0]]); + }); + + it('text, chart, text: the chart sits where the first text part ends', () => { + const intro = 'Here is the breakdown.\n\n'; + const out = uiMessageToChatMessage({ + id: 'a2', + role: 'assistant', + parts: [ + { type: 'text', text: intro }, + chartPart('c0', 'Customer health'), + { type: 'text', text: 'Three accounts need attention.' }, + ], + }); + expect(out.content).toBe(`${intro}Three accounts need attention.`); + expect(out.charts?.[0]?.textOffset).toBe(intro.length); + expect(out.content.slice(0, out.charts?.[0]?.textOffset)).toBe(intro); + }); + + it('counts only text parts: tool, step and reasoning parts between them add nothing', () => { + const out = uiMessageToChatMessage({ + id: 'a3', + role: 'assistant', + parts: [ + { type: 'step-start' }, + { type: 'text', text: 'Looking at your accounts. ' }, + { type: 'reasoning', text: 'The user wants a segment view.' }, + { type: 'tool-query_records', toolCallId: 't1', state: 'output-available', output: { rows: 40 } }, + { type: 'text', text: 'Charting them now.' }, + { type: 'tool-visualize_data', toolCallId: 't2', state: 'output-available', output: { ok: true } }, + chartPart('c0', 'Customer health'), + { type: 'step-start' }, + chartPart('c1', 'Revenue by region'), + { type: 'text', text: ' Two findings stand out.' }, + ], + }); + const before = 'Looking at your accounts. Charting them now.'; + expect(out.content).toBe(`${before} Two findings stand out.`); + expect(out.charts?.map((c) => c.textOffset)).toEqual([before.length, before.length]); + }); + + it('a chart that arrives after all the text sits at the end of the content', () => { + const out = uiMessageToChatMessage({ + id: 'a4', + role: 'assistant', + parts: [{ type: 'text', text: 'Here you go:' }, chartPart('c0', 'Customer health')], + }); + expect(out.charts?.[0]?.textOffset).toBe(out.content.length); + }); + + it('a legacy string `content` is not built from the parts, so its charts carry no offset', () => { + const out = uiMessageToChatMessage({ + id: 'a5', + role: 'assistant', + content: 'Legacy text.', + parts: [chartPart('c0', 'Customer health'), { type: 'text', text: 'ignored' }], + }); + expect(out.content).toBe('Legacy text.'); + expect(out.charts).toHaveLength(1); + expect(out.charts?.[0]).not.toHaveProperty('textOffset'); + }); + + it('re-mapping a restored message (same parts) yields the same placement', () => { + const stored = { + id: 'a6', + role: 'assistant' as const, + parts: [ + chartPart('c0', 'Customer health'), + { type: 'text', text: 'Most customers are healthy. ' }, + chartPart('c1', 'Revenue by region'), + { type: 'text', text: 'The east region leads.' }, + ], + }; + const live = uiMessagesToChatMessages([stored], { isStreaming: true })[0]; + const restored = uiMessagesToChatMessages([JSON.parse(JSON.stringify(stored))])[0]; + const placement = (m: typeof live) => m.charts?.map((c) => [c.title, c.textOffset]); + expect(placement(live)).toEqual([ + ['Customer health', 0], + ['Revenue by region', 'Most customers are healthy. '.length], + ]); + expect(placement(restored)).toEqual(placement(live)); + expect(restored.content).toBe(live.content); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts index 6a38291474..978b8de942 100644 --- a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts @@ -358,6 +358,8 @@ describe('uiMessageToChatMessage', () => { { status: 'won', count: 5 }, { status: 'lost', count: 2 }, ], + // objectui#13492 — the chart arrived after the whole text. + textOffset: 'Here is the breakdown:'.length, }); }); diff --git a/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx b/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx new file mode 100644 index 0000000000..48a7d9128a --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx @@ -0,0 +1,83 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#13492 — a re-hydrated stored message keeps its chart order. + * + * In API mode a host restores a thread by handing `useObjectChat` its stored + * messages; one that carries `parts` reaches the AI SDK store untouched, and + * the hook maps the store back on every render. The mock below stands in for + * the store: `useChat` answers with the very messages it was initialised + * with, so what the bubble renders is the restored thread, read through the + * same mapper the live stream goes through. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, renderHook, screen } from '@testing-library/react'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; +import { useObjectChat, type UseObjectChatOptions } from '../useObjectChat'; + +vi.mock('@ai-sdk/react', () => ({ + useChat: (opts: { messages?: unknown[] }) => ({ + messages: opts.messages ?? [], + status: 'ready', + error: undefined, + sendMessage: vi.fn(), + regenerate: vi.fn(), + stop: vi.fn(), + setMessages: vi.fn(), + }), +})); + +const API = 'https://example.test/api/v1/ai/agents/ask/chat'; + +/** The stored turn: the model drew the chart, then wrote its analysis. */ +const STORED: NonNullable = [ + { id: 'u1', role: 'user', content: '帮我分析一下客户', parts: [{ type: 'text', text: '帮我分析一下客户' }] }, + { + id: 'a1', + role: 'assistant', + content: '', + parts: [ + { + type: 'data-chart', + id: 'chart-0', + data: { + chartType: 'pie', + title: 'Customer health', + xAxisKey: 'segment', + series: [{ dataKey: 'count' }], + data: [{ segment: 'healthy', count: 12 }], + }, + }, + { type: 'text', text: 'Most customers are healthy.' }, + ], + }, +]; + +describe('a re-hydrated stored message keeps its chart order (objectui#13492)', () => { + it('the restored chart keeps its offset', () => { + const { result } = renderHook(() => useObjectChat({ api: API, conversationId: 'c1', initialMessages: STORED })); + const restored = result.current.messages[1]; + expect(restored.content).toBe('Most customers are healthy.'); + expect(restored.charts?.map((c) => [c.title, c.textOffset])).toEqual([['Customer health', 0]]); + }); + + it('and the bubble renders the chart above the analysis', async () => { + const { result } = renderHook(() => useObjectChat({ api: API, conversationId: 'c1', initialMessages: STORED })); + const { container } = render( + , + ); + await screen.findByText('Most customers are healthy.'); + const chart = container.querySelector('[data-testid="chat-chart"]')!; + const prose = container.querySelector('[data-chat-answer]')!; + expect(chart).toHaveTextContent('Customer health'); + expect(chart.compareDocumentPosition(prose) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index b6d9b074b1..199fb05ce3 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -1357,10 +1357,22 @@ function extractBlueprintProgress(parts: AnyPart[]): ChatBlueprintProgress | und * answer may carry several — we keep them all, in arrival order. Each part's * `data` already matches the SDUI `` schema; we defensively narrow it * so a malformed payload is dropped rather than rendered broken. + * + * objectui#13492 — each chart also records where it arrived among the text + * parts (`textOffset`: the length of the text {@link extractText} joined + * before it), so the bubble can render a chart the model drew first above the + * analysis it wrote after, instead of below all of it. Only when the content + * IS that join (`anchored`): a legacy string `content` is not built from the + * parts, so an offset into it would point nowhere and the charts trail it. */ -function extractCharts(parts: AnyPart[]): ChatChart[] | undefined { +function extractCharts(parts: AnyPart[], opts: { anchored: boolean }): ChatChart[] | undefined { const charts: ChatChart[] = []; + let textOffset = 0; for (const p of parts) { + if (p.type === 'text') { + textOffset += (p.text ?? '').length; + continue; + } if (p.type !== 'data-chart') continue; const d = p.data; if (!d || typeof d !== 'object') continue; @@ -1382,6 +1394,7 @@ function extractCharts(parts: AnyPart[]): ChatChart[] | undefined { data, ...(typeof c.xAxisKey === 'string' ? { xAxisKey: c.xAxisKey } : {}), series, + ...(opts.anchored ? { textOffset } : {}), }); } return charts.length > 0 ? charts : undefined; @@ -1431,7 +1444,7 @@ export function uiMessageToChatMessage( // part like buildProgress. No persisted fallback needed: the `proposedPlan` // card (from the persisted tool result) is the post-reload record. blueprintProgress: extractBlueprintProgress(parts), - charts: extractCharts(parts), + charts: extractCharts(parts, { anchored: typeof msg.content !== 'string' }), streaming: opts.streaming, }; } From cb0af9656b697e19145c7d2bc3c8f773dc6300eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:06:14 +0000 Subject: [PATCH 2/2] test(plugin-chatbot): feed the restored thread through the runtime seam Claude-Session: https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o Co-authored-by: Claude --- .../__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx b/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx index 48a7d9128a..f2cec2d544 100644 --- a/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/useObjectChat.chartOrderRehydrate-13492.test.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { render, renderHook, screen } from '@testing-library/react'; import { ChatbotEnhanced } from '../ChatbotEnhanced'; +import { toRuntimeMessages } from '../chatMessageAdapter'; import { useObjectChat, type UseObjectChatOptions } from '../useObjectChat'; vi.mock('@ai-sdk/react', () => ({ @@ -72,7 +73,7 @@ describe('a re-hydrated stored message keeps its chart order (objectui#13492)', it('and the bubble renders the chart above the analysis', async () => { const { result } = renderHook(() => useObjectChat({ api: API, conversationId: 'c1', initialMessages: STORED })); const { container } = render( - , + , ); await screen.findByText('Most customers are healthy.'); const chart = container.querySelector('[data-testid="chat-chart"]')!;