Skip to content

Commit 76c560e

Browse files
fix(plugin-chatbot): render an answer's text and charts in stream order (objectui#13492) (#13500)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D1ujqNbMkVbXgPHgdDMe18r)_ Fixes #13492 Clause-②: no Metadata-protocol: none ## What changed 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, so an open-ended analysis in the console's assistant panel puts its chart on the first screen (the acceptance objectstack-ai/cloud#3072 sets). Before, `mapMessages` joined every text part into one `content` and lifted every `data-chart` part into one trailing `charts[]`, and `ChatbotEnhanced` rendered the whole text first, so the chart always landed below every paragraph. - **`mapMessages.ts`:** `extractCharts` now records, on each chart, where it arrived among the text parts: `textOffset` = the length of the text that `extractText` joined before it. Tool, step and reasoning parts add nothing. Only when `content` IS that join; a legacy string `content` is not built from the parts, so its charts carry no offset. - **`ChatbotEnhanced.tsx`:** a new optional `ChatChart.textOffset`. `layoutAnswer` splits `content` at the offsets into text runs and chart runs; each text run renders through its own `MessageResponse` (so long-answer folding and record links work in every run), and each chart run through the same chart markup as before, now in `AnswerCharts`. The streaming caret rides the last text run, where it sat before. Charts render in the same tree position whether or not prose has arrived, so a chart drawn before the first word keeps its mounted node when the text streams in. - **Backward-compatible:** `content` and `charts` keep their shape. A chart with no `textOffset` (a host-built message, the legacy string-content shape) or one past the end of the text trails all of it, which is exactly the old layout; a message with no charts renders the same DOM as before. `textOffset` is a placement key and never reaches the chart schema handed to `SchemaRenderer`. ## The card's acceptance, item by item | Acceptance | Pinned by | |---|---| | parts `data-chart`, `text`: the chart renders before the text | `data-chart then text: the chart renders before the analysis` | | parts `text`, `data-chart`, `text`: the three in that order | `text, data-chart, text: the three render in that order` | | a re-hydrated stored message keeps the order | `useObjectChat.chartOrderRehydrate-13492.test.tsx` (API mode, stored message with `parts` through the hook and into the bubble) and `re-mapping a restored message (same parts) yields the same placement` | | long-answer folding (objectui#13032) does not regress | `objectui#13032 does not regress: the long analysis after a chart still folds once the stream ends`, plus the unchanged `chatAnswerLayout-13032.test.tsx` | | record links (objectui#13037) do not regress | `objectui#13037 does not regress: a record link in the text after a chart opens through the host opener`, plus the unchanged `chatRecordLink-13037.test.tsx` | | staging re-test with 「帮我分析一下客户」 | **NOT MEASURED**: staging reaches this through cloud's objectui pin; re-run after the pin moves past this merge | New files: `mapMessages.chartOrder-13492.test.ts` (6 tests), `ChatbotEnhanced.chartOrder-13492.test.tsx` (7, one a CONTROL for the old layout), `useObjectChat.chartOrderRehydrate-13492.test.tsx` (2). `mapMessages.test.ts` gains the `textOffset` in the one test that pins a chart's exact shape. ## Verification (local, head `cb0af96`) All runs from the repo root under the shared verify lock. - `pnpm exec vitest run --maxWorkers=2 packages/plugin-chatbot/`: **Test Files 82 passed (82), Tests 787 passed (787)**. - The 54 test files in `packages/app-shell/src` and `apps/console/src` that import `@object-ui/plugin-chatbot` (resolved to its `src` by the root config's alias): **Test Files 54 passed (54), Tests 582 passed (582)**. - `pnpm turbo run type-check --filter=@object-ui/plugin-chatbot` (builds the dependency closure, then `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 after one fix to the new re-hydrate test (it now hands the hook's messages through `toRuntimeMessages`, as hosts do). - `eslint --no-inline-config` on the six touched source and test files: 0 errors; the 11 warnings are all on lines this PR does not touch. - `node scripts/check-changeset-presence.mjs` and `node scripts/check-changeset-no-major.mjs`: pass. `pnpm check:new-line-citations`: 0 new citations. `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:control-bytes`, `check:ui-root-lookups`, `check:unreferenced-sources`: all exit 0. - **Reverse verification**, both through `ablation-replace.mjs` with the fix committed and restore proven (blob equals HEAD, `git diff HEAD` empty): - renderer: `layoutAnswer` ignores `textOffset` (every chart trails the text) ⇒ 6 of the 9 render and re-hydrate tests turn red; the record-link test, the old-layout CONTROL and the mapper tests stay green, as they should. - mapper: the `textOffset` spread deleted ⇒ 13 tests red across the four files, including the existing exact-shape test; the legacy no-offset test stays green. - NOT MEASURED locally, left to CI: the sharded full `pnpm test`, repo-wide `pnpm lint`, builds and the remaining `check:*` gates. ## Acceptance notes - **No reload path in this repository restores a chart today, so on reload the chart is missing, not misplaced.** The server keeps a conversation as Vercel `ModelMessage`s (`IAIConversationService.addMessage` in `@objectstack/spec`'s `contracts/ai-service.ts`), which have no data parts. On the way back, app-shell's `hydratedMessagesToChatMessages` builds `content` and tool invocations only, and `sanitizeChatMessagesForCache` writes text and tool parts only. Read from code, not measured on staging; outside this card's file surface (`packages/plugin-chatbot/src/**`). A stored message that does carry its `data-chart` parts keeps its order (pinned above). Whether cloud's `visualize_data` result carries enough to re-derive the chart on reload is a cloud-side reading this session cannot reach. - In an answer that interleaves text and charts, each text run folds on its own (a run taller than two viewport heights folds; a short intro above the chart does not). An answer with no chart, or with its charts after all the text, is one run and folds exactly as before. - Docs: neither `packages/plugin-chatbot/README.md` nor `content/docs/plugins/plugin-chatbot.mdx` describes where charts render, so nothing there went stale; `textOffset` is documented on the `ChatChart` type. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Card thread session: `session_01VWbYRWtBm3f8J2T8HAt97o`, claimed by the `domain:ui` seat 4 (claim comment 6393183623). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o --- _Generated by [Claude Code](https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebd20ec commit 76c560e

7 files changed

Lines changed: 548 additions & 55 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@object-ui/plugin-chatbot": patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
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 `<ChatbotEnhanced>` 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.

‎packages/plugin-chatbot/src/ChatbotEnhanced.tsx‎

Lines changed: 138 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,9 @@ export interface ChatbotEnhancedMessage {
154154

155155
/**
156156
* A chart lifted from a `data-chart` stream part. Mirrors the `schema` prop of
157-
* the SDUI `<chart>` renderer (plugin-charts `ChartRenderer`), so it can be
158-
* fed straight into `<SchemaRenderer schema={{ type: 'chart', ...chart }} />`.
157+
* the SDUI `<chart>` renderer (plugin-charts `ChartRenderer`), so its chart
158+
* keys can be fed straight into `<SchemaRenderer schema={{ type: 'chart', … }} />`;
159+
* `textOffset` is the one key that places the chart in the bubble instead.
159160
*/
160161
export interface ChatChart {
161162
chartType?:
@@ -176,6 +177,16 @@ export interface ChatChart {
176177
xAxisKey?: string;
177178
/** One entry per plotted measure. `dataKey` is the row column to read. */
178179
series: Array<{ dataKey: string; label?: string }>;
180+
/**
181+
* Where the chart sits in the answer: how many characters of the message's
182+
* `content` the stream produced before it (objectui#13492). The bubble
183+
* renders the text up to that point, then the chart, then the rest, so a
184+
* chart the model drew before writing its analysis shows first. Absent, or
185+
* past the end of `content`, the chart renders after all the text.
186+
* `mapMessages` sets it from the part order; it is a placement key and never
187+
* reaches the `<chart>` schema.
188+
*/
189+
textOffset?: number;
179190
}
180191

181192
/**
@@ -1711,6 +1722,89 @@ function isToolCallPlaceholder(content: string): boolean {
17111722
return /^\((?:called [^)]*|tool call|no content)\)$/.test(content.trim());
17121723
}
17131724

1725+
/** One run of an assistant answer: a slice of its text, or the charts drawn at one point. */
1726+
type AnswerBlock =
1727+
| { kind: 'text'; text: string }
1728+
| { kind: 'charts'; charts: ChatChart[] };
1729+
1730+
/**
1731+
* objectui#13492 — an assistant answer in the order the stream produced it:
1732+
* the text up to each chart's `textOffset`, the chart, then the text after.
1733+
* The mapper used to flatten every text part into one `content` and every
1734+
* `data-chart` part into one trailing `charts[]` block, so a chart the model
1735+
* drew first still landed below six paragraphs of analysis.
1736+
*
1737+
* A chart without an offset (a host-built message, the legacy string-content
1738+
* shape) or with one past the text's end trails all of it, which is exactly
1739+
* the old layout. Charts at the same offset share one block, and a slice that
1740+
* is only whitespace is dropped rather than painted as an empty answer box.
1741+
*/
1742+
function layoutAnswer(content: string, charts: ChatChart[] | undefined): AnswerBlock[] {
1743+
const placed = (charts ?? [])
1744+
.map((chart, order) => ({
1745+
chart,
1746+
order,
1747+
at:
1748+
typeof chart.textOffset === 'number' && Number.isFinite(chart.textOffset)
1749+
? Math.min(Math.max(chart.textOffset, 0), content.length)
1750+
: content.length,
1751+
}))
1752+
.sort((a, b) => a.at - b.at || a.order - b.order);
1753+
const blocks: AnswerBlock[] = [];
1754+
let cursor = 0;
1755+
for (const { chart, at } of placed) {
1756+
if (at > cursor) {
1757+
const text = content.slice(cursor, at);
1758+
if (text.trim().length > 0) blocks.push({ kind: 'text', text });
1759+
cursor = at;
1760+
}
1761+
const last = blocks[blocks.length - 1];
1762+
if (last?.kind === 'charts') last.charts.push(chart);
1763+
else blocks.push({ kind: 'charts', charts: [chart] });
1764+
}
1765+
const rest = content.slice(cursor);
1766+
if (rest.trim().length > 0) blocks.push({ kind: 'text', text: rest });
1767+
return blocks;
1768+
}
1769+
1770+
/** The charts drawn at one point of an assistant answer, each through the SDUI `<chart>`. */
1771+
function AnswerCharts({ charts }: { charts: ChatChart[] }) {
1772+
return (
1773+
<div className="mt-2 flex flex-col gap-3" data-testid="chat-charts">
1774+
{charts.map((chart, i) => (
1775+
<div
1776+
key={i}
1777+
className="rounded-lg border bg-background p-3"
1778+
// The chat bubble is `w-fit` (shrinks to content) while the
1779+
// SDUI chart's ResponsiveContainer is `width:100%` — a
1780+
// circular dependency. We give the chart a DEFINITE width
1781+
// sized to the viewport (NOT the shrink-to-fit parent, which
1782+
// would re-introduce the cycle) so recharts always measures a
1783+
// stable, non-zero width and renders. Height comes from the
1784+
// ChartContainer itself (h-[350px] / min-h 280).
1785+
style={{ width: 'min(520px, 80vw)' }}
1786+
data-testid="chat-chart"
1787+
>
1788+
{chart.title ? (
1789+
<div className="mb-2 text-sm font-medium text-foreground">
1790+
{chart.title}
1791+
</div>
1792+
) : null}
1793+
<SchemaRenderer
1794+
schema={{
1795+
type: 'chart',
1796+
chartType: chart.chartType ?? 'bar',
1797+
data: chart.data,
1798+
xAxisKey: chart.xAxisKey,
1799+
series: chart.series,
1800+
} as never}
1801+
/>
1802+
</div>
1803+
))}
1804+
</div>
1805+
);
1806+
}
1807+
17141808
/**
17151809
* The "1 object · 1 view · 1 dashboard" strip under a proposed plan / a live
17161810
* design panel, localized (objectui#7254).
@@ -4078,6 +4172,14 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
40784172
detailedTools.length > 0 ||
40794173
Boolean(buildProgress) ||
40804174
Boolean(blueprintProgress);
4175+
// objectui#13492 — the answer body in stream order; the
4176+
// streaming cursor rides the last text run, where it sat when
4177+
// the charts always trailed the text.
4178+
const answerBlocks = isUser ? undefined : layoutAnswer(message.content, message.charts);
4179+
let lastTextBlock = -1;
4180+
answerBlocks?.forEach((block, i) => {
4181+
if (block.kind === 'text') lastTextBlock = i;
4182+
});
40814183
return (
40824184
<Message key={message.id} from={formatMessageProps(message.role)}>
40834185
<div
@@ -4169,18 +4271,10 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
41694271
) : null
41704272
) : isEmptyAssistantStreaming ? (
41714273
<ThinkingDots />
4172-
) : hasVisibleProse ? (
4173-
// objectui#13032 — a long answer folds only once its
4174-
// stream has ended; the streaming flag tells it when.
4175-
// objectui#13037 — a record link in the answer opens
4176-
// the record through the same host opener as the
4177-
// `open_record` card.
4178-
<RecordLinkContext.Provider value={onOpenRecord ?? null}>
4179-
<MessageResponse isAnimating={Boolean(message.streaming)}>
4180-
{message.content}
4181-
</MessageResponse>
4182-
</RecordLinkContext.Provider>
4183-
) : !message.streaming && !showsActivity && isToolCallPlaceholder(message.content) ? (
4274+
) : !hasVisibleProse &&
4275+
!message.streaming &&
4276+
!showsActivity &&
4277+
isToolCallPlaceholder(message.content) ? (
41844278
// #772: a re-hydrated tool-call-only turn is persisted with
41854279
// an internal placeholder ("(called todo_write,
41864280
// propose_blueprint)"); render a quiet localized activity
@@ -4194,45 +4288,36 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
41944288
{L.agentActivity}
41954289
</span>
41964290
) : null}
4197-
{message.streaming && hasVisibleProse ? (
4198-
<span
4199-
aria-hidden
4200-
className="ml-0.5 inline-block w-[2px] h-4 align-middle bg-current animate-pulse"
4201-
/>
4202-
) : null}
4203-
{!isUser && (message.charts?.length ?? 0) > 0 ? (
4204-
<div className="mt-2 flex flex-col gap-3" data-testid="chat-charts">
4205-
{message.charts!.map((chart, i) => (
4206-
<div
4207-
key={i}
4208-
className="rounded-lg border bg-background p-3"
4209-
// The chat bubble is `w-fit` (shrinks to content) while the
4210-
// SDUI chart's ResponsiveContainer is `width:100%` — a
4211-
// circular dependency. We give the chart a DEFINITE width
4212-
// sized to the viewport (NOT the shrink-to-fit parent, which
4213-
// would re-introduce the cycle) so recharts always measures a
4214-
// stable, non-zero width and renders. Height comes from the
4215-
// ChartContainer itself (h-[350px] / min-h 280).
4216-
style={{ width: 'min(520px, 80vw)' }}
4217-
data-testid="chat-chart"
4218-
>
4219-
{chart.title ? (
4220-
<div className="mb-2 text-sm font-medium text-foreground">
4221-
{chart.title}
4222-
</div>
4223-
) : null}
4224-
<SchemaRenderer
4225-
schema={{
4226-
type: 'chart',
4227-
chartType: chart.chartType ?? 'bar',
4228-
data: chart.data,
4229-
xAxisKey: chart.xAxisKey,
4230-
series: chart.series,
4231-
} as never}
4232-
/>
4233-
</div>
4234-
))}
4235-
</div>
4291+
{answerBlocks ? (
4292+
// objectui#13492 — the answer's text and charts in
4293+
// stream order (see `layoutAnswer`). A turn with no
4294+
// showable prose still draws its charts here, so a
4295+
// chart drawn before the first word keeps its place
4296+
// (and its mounted chart) once the text arrives.
4297+
// objectui#13032 — a long answer folds only once its
4298+
// stream has ended; the streaming flag tells it when.
4299+
// objectui#13037 — a record link in the answer opens
4300+
// the record through the same host opener as the
4301+
// `open_record` card.
4302+
<RecordLinkContext.Provider value={onOpenRecord ?? null}>
4303+
{answerBlocks.map((block, i) =>
4304+
block.kind === 'charts' ? (
4305+
<AnswerCharts key={i} charts={block.charts} />
4306+
) : hasVisibleProse ? (
4307+
<React.Fragment key={i}>
4308+
<MessageResponse isAnimating={Boolean(message.streaming)}>
4309+
{block.text}
4310+
</MessageResponse>
4311+
{message.streaming && i === lastTextBlock ? (
4312+
<span
4313+
aria-hidden
4314+
className="ml-0.5 inline-block w-[2px] h-4 align-middle bg-current animate-pulse"
4315+
/>
4316+
) : null}
4317+
</React.Fragment>
4318+
) : null,
4319+
)}
4320+
</RecordLinkContext.Provider>
42364321
) : null}
42374322
{!isUser && sources.length > 0 ? (
42384323
<Sources>

0 commit comments

Comments
 (0)