diff --git a/.env.example b/.env.example
index 8d715703..e5f5288f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,8 @@
# AI Provider (at least one required)
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-# ANTHROPIC_MODEL=claude-sonnet-4-20250514
+# ANTHROPIC_MODEL=claude-opus-5
+# Required when the key above is identity-linked (scoped to all workspaces).
+# ANTHROPIC_WORKSPACE_ID=wrkspc_xxxxxxxxxxxxxxxxxxxxxxx
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENAI_MODEL=gpt-4-turbo
diff --git a/CLAUDE.md b/CLAUDE.md
index 43662375..e51248f5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,6 +51,7 @@ bun run kill # Kill processes on ports 3000, 3002, 8081
**Server:**
- `ANTHROPIC_API_KEY` (required)
+- `ANTHROPIC_WORKSPACE_ID` (required for an identity-linked key): sent as the `anthropic-workspace-id` header
- `LOG_FORMAT` (optional): `pretty` (default) or `json`
- `RATE_LIMIT_MAX_REQUESTS` (optional): Max requests per window (0 = unlimited)
- `RATE_LIMIT_WINDOW_MS` (optional): Window in ms (default: 60000)
diff --git a/README.md b/README.md
index 15ded121..84ce4d36 100644
--- a/README.md
+++ b/README.md
@@ -131,7 +131,7 @@ services:
# - OPENAI_API_KEY=${OPENAI_API_KEY}
# Optional: Model selection
- # - ANTHROPIC_MODEL=claude-sonnet-4-20250514
+ # - ANTHROPIC_MODEL=claude-opus-5
# - OPENAI_MODEL=gpt-4-turbo
# Optional: Server configuration
@@ -723,6 +723,79 @@ root.render(
);
```
+For partial customization, override only the built-in chat regions you own.
+Every slot receives the built-in UI as `children`: render it to decorate the
+default behavior, or omit it to replace that region completely.
+
+```tsx
+import type {
+ ChatMessageSlotProps,
+ ChatComposerSlotProps,
+} from '@meetsmore-oss/use-ai-client';
+
+function Message({ message, children }: ChatMessageSlotProps) {
+ return
{children}
;
+}
+
+function Composer({ input, onInputChange, onSend, canSend }: ChatComposerSlotProps) {
+ return (
+
+ );
+}
+
+
+
+
+```
+
+Available slots are `Header`, `EmptyState`, `Message`, `PendingIndicator`,
+`Composer`, `ToolApproval`, and `Disclaimer`. Provider-level `chatComponents` apply to all
+chat instances. A specific `` takes precedence.
+
+**Migrating a `CustomChat` from 1.17:** `ChatPanelProps` no longer carries
+`streamingText` and `streamingReasoning`, and `ReasoningProps` no longer carries
+`streamingText`. The in-flight answer now arrives as `streamingParts`, the
+ordered parts of the run. Flatten them with the exported helpers:
+
+```tsx
+import {
+ getTextFromStreamingParts,
+ getReasoningPartsFromStreamingParts,
+} from '@meetsmore-oss/use-ai-client';
+
+const streamingText = getTextFromStreamingParts(streamingParts);
+const streamingReasoning = getReasoningPartsFromStreamingParts(streamingParts);
+```
+
+The answer being streamed goes through `Message` too, as a provisional entry
+carrying the id it will be persisted under, so `streaming` tells the two apart.
+
+Each region's built-in implementation is exported as `DefaultHeader`,
+`DefaultMessage` and so on, taking exactly the props its slot receives. Reuse
+one when only part of a region needs to change:
+
+```tsx
+import { DefaultMessage, type ChatMessageSlotProps } from '@meetsmore-oss/use-ai-client';
+
+function Message(props: ChatMessageSlotProps) {
+ return (
+ <>
+
+ {props.isLast && }
+ >
+ );
+}
+```
+
+A working example of every slot, including a turn rendered as a timeline, lives
+in `apps/example` at `/custom-slots-demo`.
+
You can also disable them by passing `null`:
```tsx
@@ -927,7 +1000,8 @@ For most use cases, you can just use `@meetsmore-oss/use-ai-server` as-is, and c
```bash
# AI Provider (at least one required)
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-# ANTHROPIC_MODEL=claude-sonnet-4-20250514
+# ANTHROPIC_MODEL=claude-opus-5
+# ANTHROPIC_WORKSPACE_ID=wrkspc_xxxxxxxxxxxxxxxxxxxxxxx # Required for an identity-linked key
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENAI_MODEL=gpt-4-turbo
@@ -970,7 +1044,7 @@ const server = new UseAIServer({
'claude': new AISDKAgent({
name: 'Claude',
annotation: 'Powered by Anthropic', // shown in agent selector UI
- hooks: { loadConfig: () => ({ model: anthropic('claude-sonnet-4-20250514') }) },
+ hooks: { loadConfig: () => ({ model: anthropic('claude-opus-5') }) },
})
},
defaultAgent: 'claude',
diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 09873e90..e9ed5cda 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -17,6 +17,7 @@ import ClientToolsPage from './pages/ClientToolsPage';
import ChatHistoryPage from './pages/ChatHistoryPage';
import SlashCommandsPage from './pages/SlashCommandsPage';
import CustomUIPage from './pages/CustomUIPage';
+import CustomSlotsDemoPage from './pages/CustomSlotsDemoPage';
import ThemeI18nPage from './pages/ThemeI18nPage';
import SuggestionsPage from './pages/SuggestionsPage';
import DestructiveApprovalPage from './pages/DestructiveApprovalPage';
@@ -66,6 +67,7 @@ const NAV_CATEGORIES: NavCategory[] = [
{
label: 'UI Customization',
items: [
+ { path: '/custom-slots-demo', label: 'Component Slots Demo' },
{ path: '/custom-ui', label: 'Custom UI' },
{ path: '/theme-i18n', label: 'Theme & i18n' },
{ path: '/submit-mode', label: 'Submit Mode (Enter key)' },
@@ -174,6 +176,7 @@ function AppContent() {
+
diff --git a/apps/example/src/pages/CustomSlotsDemoPage.tsx b/apps/example/src/pages/CustomSlotsDemoPage.tsx
new file mode 100644
index 00000000..7ff8a0ad
--- /dev/null
+++ b/apps/example/src/pages/CustomSlotsDemoPage.tsx
@@ -0,0 +1,633 @@
+import React from 'react';
+import { z } from 'zod';
+import {
+ UseAIChat,
+ useAI,
+ defineTool,
+ type ChatComposerSlotProps,
+ type ChatDisclaimerSlotProps,
+ type ChatEmptyStateSlotProps,
+ type ChatHeaderSlotProps,
+ type ChatMessageSlotProps,
+ type ChatPendingIndicatorSlotProps,
+ type ChatToolApprovalSlotProps,
+ type PersistedMessageContent,
+ type UseAIChatComponentOverrides,
+} from '@meetsmore-oss/use-ai-client';
+import { CollapsibleCode } from '../components/CollapsibleCode';
+import { docStyles } from '../styles/docStyles';
+import {
+ OrbitCitations,
+ OrbitProse,
+ OrbitReasoning,
+ OrbitToolCard,
+ buildStreamingTimeline,
+ buildTimeline,
+ collectSources,
+} from './orbitTimeline';
+
+const FALLBACK_SUGGESTIONS = [
+ 'Look up how component slots work and cite your sources',
+ 'Work out the orbital period for a 7000 km radius, with the formula',
+ 'Search the handbook for tool approval, then summarize it',
+];
+
+function contentToText(content: PersistedMessageContent): string {
+ if (typeof content === 'string') return content;
+
+ return content
+ .map((part) => {
+ if (part.type === 'text' || part.type === 'transformed_file') return part.text;
+ if (part.type === 'file') return `Attachment: ${part.file.name}`;
+ if (part.type === 'attachment_ref') return `Attachment: ${part.name}`;
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n');
+}
+
+function OrbitHeader({
+ connected,
+ messages,
+ availableAgents,
+ defaultAgent,
+ selectedAgent,
+ onAgentChange,
+ onNewChat,
+}: ChatHeaderSlotProps) {
+ const activeAgent = availableAgents.find((agent) => agent.id === (selectedAgent ?? defaultAgent));
+
+ return (
+
+
+
+ );
+ }
+
+ // A run in flight and a persisted turn reduce to the same entries, so the
+ // layout below does not change when the answer completes.
+ const entries = streaming ? buildStreamingTimeline(streamingParts) : buildTimeline(sourceMessages);
+ const sources = collectSources(entries);
+ const lastEntry = entries[entries.length - 1];
+
+ return (
+
+
+ {/* A run has its parts before it has content: the meta line above is
+ already saying it is writing, so the bubble stays empty until the
+ first entry arrives. */}
+ {entries.length === 0 && !streaming ? (
+
+ ) : (
+ entries.map((entry) => {
+ if (entry.kind === 'reasoning') {
+ // Open the block still being written so the run reads as it happens.
+ return ;
+ }
+ if (entry.kind === 'tool') return ;
+ return ;
+ })
+ )}
+ {sources.length > 0 && }
+
+
+ );
+}
+
+function OrbitComposer({
+ input,
+ connected,
+ loading,
+ placeholder,
+ canSend,
+ canAbort,
+ attachments,
+ fileUploadEnabled,
+ fileError,
+ pendingApprovals,
+ onInputChange,
+ onSend,
+ onAbort,
+ onOpenFilePicker,
+ onRemoveAttachment,
+}: ChatComposerSlotProps) {
+ // ToolApproval is rendered independently by use-ai. Hide the composer while
+ // an approval is pending to match the built-in interaction pattern.
+ if (pendingApprovals.length > 0) return null;
+
+ return (
+
+ );
+}
+
+function OrbitToolApproval({ approvals, onApprove, onReject }: ChatToolApprovalSlotProps) {
+ const firstApproval = approvals[0];
+ if (!firstApproval) return null;
+
+ return (
+
+ );
+}
+
+function OrbitDisclaimer({ text }: ChatDisclaimerSlotProps) {
+ return (
+
+ ✦ {text}
+
+ );
+}
+
+const orbitComponents: UseAIChatComponentOverrides = {
+ Header: OrbitHeader,
+ EmptyState: OrbitEmptyState,
+ Message: OrbitMessage,
+ PendingIndicator: OrbitPendingIndicator,
+ Composer: OrbitComposer,
+ ToolApproval: OrbitToolApproval,
+ Disclaimer: OrbitDisclaimer,
+};
+
+/**
+ * Stand-in search index. The point of the demo is the rendering, so the
+ * "sources" are fixed rather than fetched.
+ */
+const HANDBOOK = [
+ {
+ title: 'Component slots',
+ url: 'https://example.com/handbook/component-slots',
+ snippet: 'Each region of the chat is a React component the host app supplies.',
+ body: 'Slots receive live chat state and callbacks. Omit children to replace a region outright.',
+ },
+ {
+ title: 'Tool approval',
+ url: 'https://example.com/handbook/tool-approval',
+ snippet: 'Tools marked destructive wait for an explicit confirmation.',
+ body: 'A tool annotated with destructiveHint pauses the run until the user approves it.',
+ },
+ {
+ title: 'Chat history',
+ url: 'https://example.com/handbook/chat-history',
+ snippet: 'Conversations persist through a ChatRepository.',
+ body: 'The default repository stores the twenty most recent chats in localStorage.',
+ },
+];
+
+const searchHandbook = defineTool(
+ 'Search the Orbit handbook. Returns matching passages and the sources they came from.',
+ z.object({ query: z.string() }),
+ ({ query }) => {
+ const needle = query.toLowerCase();
+ const hits = HANDBOOK.filter(
+ (entry) => entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
+ );
+ const matches = hits.length > 0 ? hits : HANDBOOK;
+
+ return {
+ passages: matches.map((entry) => entry.body),
+ // Read back by the Message slot to render the citation cards.
+ sources: matches.map(({ title, url, snippet }) => ({ title, url, snippet })),
+ };
+ }
+);
+
+const computeOrbitalPeriod = defineTool(
+ 'Compute the orbital period for a circular orbit of the given radius around Earth.',
+ z.object({ radiusKm: z.number() }),
+ ({ radiusKm }) => {
+ const mu = 398600.4418; // km^3/s^2
+ const seconds = 2 * Math.PI * Math.sqrt(radiusKm ** 3 / mu);
+
+ return {
+ radiusKm,
+ periodMinutes: Number((seconds / 60).toFixed(2)),
+ formula: 'T = 2\\pi\\sqrt{\\frac{r^{3}}{\\mu}}',
+ sources: [
+ {
+ title: 'Orbital mechanics primer',
+ url: 'https://example.com/handbook/orbital-mechanics',
+ snippet: 'Vis-viva and the circular orbit period.',
+ },
+ ],
+ };
+ }
+);
+
+const SLOT_NAMES = [
+ 'Header: brand mark, agent picker, connection status',
+ 'Empty state: kicker, intro, prompt list',
+ 'Message: the whole turn, drawn as a timeline',
+ 'Pending indicator: writing dots on the message meta line',
+ 'Composer: textarea, attachments, send and stop',
+ 'Tool approval: inline approve and reject row',
+ 'Disclaimer: footer line under the composer',
+];
+
+const CODE_EXAMPLE = `import {
+ UseAIChat,
+ type ChatMessageSlotProps,
+ type UseAIChatComponentOverrides,
+} from '@meetsmore-oss/use-ai-client';
+
+function OrbitMessage({ message, sourceMessages, streaming, streamingParts }: ChatMessageSlotProps) {
+ const entries = streaming ? buildStreamingTimeline(streamingParts) : buildTimeline(sourceMessages);
+
+ return {entries.map(renderEntry)};
+}
+
+const orbitComponents: UseAIChatComponentOverrides = {
+ Header: OrbitHeader,
+ EmptyState: OrbitEmptyState,
+ Message: OrbitMessage,
+ PendingIndicator: OrbitPendingIndicator,
+ Composer: OrbitComposer,
+ ToolApproval: OrbitToolApproval,
+ Disclaimer: OrbitDisclaimer,
+};
+
+`;
+
+export default function CustomSlotsDemoPage() {
+ useAI({
+ tools: { searchHandbook, computeOrbitalPeriod },
+ prompt: [
+ 'The user is viewing the Orbit component slots customization demo.',
+ 'Use searchHandbook for questions about this library, and computeOrbitalPeriod for orbit questions.',
+ 'Write mathematics as LaTeX between $ for inline and $$ for display; the UI renders it.',
+ ].join('\n'),
+ suggestions: FALLBACK_SUGGESTIONS,
+ });
+
+ return (
+
+
+
Component Slots Demo
+
+
+
About
+
+ Pass components to{' '}
+ UseAIChat to replace a region of the chat with your own
+ component. This page replaces all seven regions. The protocol, the state and the streaming
+ stay inside use-ai.
+
+
+ {SLOT_NAMES.map((name) => (
+
{name}
+ ))}
+
+
+
+
+
Live Demo
+
+ Ask a question to see the slots draw a turn. The page registers two tools:{' '}
+ searchHandbook returns sources, and{' '}
+ computeOrbitalPeriod returns a formula in LaTeX.
+
+
+
+
+
+
+
+
+
+
+
Code Example
+ {CODE_EXAMPLE}
+
+
+
+
How It Works
+
+
Each slot is a React component. It receives the live chat state and the callbacks as props.
+
+ The Message slot builds the timeline from{' '}
+ sourceMessages: reasoning, tool cards and prose, in the
+ order the model produced them.
+
+
+ While the answer streams, the same slot reads{' '}
+ streamingParts. The layout therefore does not change when
+ the run finishes.
+
+
Tool results carry sources. The slot pins the sources under the answer.
+
+ Render children to decorate a built-in region. Omit{' '}
+ children to replace the region.
+
diff --git a/apps/example/src/pages/MultiAgentPage.tsx b/apps/example/src/pages/MultiAgentPage.tsx
index 2660619b..43c0f2fb 100644
--- a/apps/example/src/pages/MultiAgentPage.tsx
+++ b/apps/example/src/pages/MultiAgentPage.tsx
@@ -91,7 +91,7 @@ new UseAIServer({
agents: {
claude: new AISDKAgent({
name: 'Claude',
- hooks: { loadConfig: () => ({ model: anthropic('claude-sonnet-4-20250514') }) },
+ hooks: { loadConfig: () => ({ model: anthropic('claude-opus-5') }) },
}),
gpt: new AISDKAgent({
name: 'GPT-4o',
diff --git a/apps/example/src/pages/orbitTimeline.tsx b/apps/example/src/pages/orbitTimeline.tsx
new file mode 100644
index 00000000..842ac731
--- /dev/null
+++ b/apps/example/src/pages/orbitTimeline.tsx
@@ -0,0 +1,405 @@
+import React from 'react';
+import type { ChatStreamingPart, PersistedMessage } from '@meetsmore-oss/use-ai-client';
+
+/**
+ * Rendering pieces for the component-slots demo.
+ *
+ * The built-in `Message` slot renders one turn as a single bubble. These pieces
+ * take the same turn apart again using `sourceMessages` and lay it out as a
+ * timeline: reasoning, tool cards and prose appear in the order the model
+ * produced them, and the sources a tool returned are pinned under the answer.
+ */
+
+// ── LaTeX ───────────────────────────────────────────────────────────────────
+
+const SYMBOLS: Record = {
+ alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', theta: 'θ', lambda: 'λ',
+ mu: 'μ', pi: 'π', rho: 'ρ', sigma: 'σ', phi: 'φ', omega: 'ω',
+ Delta: 'Δ', Sigma: 'Σ', Omega: 'Ω',
+ times: '×', cdot: '·', pm: '±', leq: '≤', geq: '≥', neq: '≠',
+ approx: '≈', infty: '∞', rightarrow: '→', partial: '∂', int: '∫', sum: '∑',
+};
+
+/** Reads a `{...}` group starting at `i`, returning its body and the index after it. */
+function readGroup(tex: string, i: number): [string, number] {
+ if (tex[i] !== '{') return [tex[i] ?? '', i + 1];
+ let depth = 0;
+ for (let j = i; j < tex.length; j++) {
+ if (tex[j] === '{') depth++;
+ else if (tex[j] === '}' && --depth === 0) return [tex.slice(i + 1, j), j + 1];
+ }
+ return [tex.slice(i + 1), tex.length];
+}
+
+/**
+ * A deliberately small TeX subset: fractions, roots, super/subscripts and the
+ * symbols above. Enough to show that the slot owns the renderer; a real app
+ * would hand the same string to KaTeX or MathJax here.
+ */
+function renderMath(tex: string, keyPrefix: string): React.ReactNode[] {
+ const out: React.ReactNode[] = [];
+ let buffer = '';
+ let i = 0;
+
+ const flush = () => {
+ if (buffer) {
+ out.push(buffer);
+ buffer = '';
+ }
+ };
+
+ while (i < tex.length) {
+ const char = tex[i];
+
+ if (char === '\\') {
+ const command = /^[a-zA-Z]+/.exec(tex.slice(i + 1))?.[0];
+ if (!command) { buffer += tex[i + 1] ?? ''; i += 2; continue; }
+ i += 1 + command.length;
+
+ if (command === 'frac') {
+ const [numerator, afterNum] = readGroup(tex, i);
+ const [denominator, afterDen] = readGroup(tex, afterNum);
+ i = afterDen;
+ flush();
+ out.push(
+
+ {renderMath(numerator, `${keyPrefix}-fn${i}`)}
+ {renderMath(denominator, `${keyPrefix}-fd${i}`)}
+
+ );
+ continue;
+ }
+
+ if (command === 'sqrt') {
+ const [body, after] = readGroup(tex, i);
+ i = after;
+ flush();
+ out.push(
+
+ {renderMath(body, `${keyPrefix}-rb${i}`)}
+
+ );
+ continue;
+ }
+
+ buffer += SYMBOLS[command] ?? command;
+ continue;
+ }
+
+ if (char === '^' || char === '_') {
+ const [body, after] = readGroup(tex, i + 1);
+ i = after;
+ flush();
+ const Tag = char === '^' ? 'sup' : 'sub';
+ out.push({renderMath(body, `${keyPrefix}-sb${i}`)});
+ continue;
+ }
+
+ buffer += char;
+ i++;
+ }
+
+ flush();
+ return out;
+}
+
+/**
+ * Assistant prose with `$…$` inline and `$$…$$` display math pulled out.
+ *
+ * The answer is laid out one line per element instead of one node for the whole
+ * text. Rewriting a text node collapses any live range inside it: the DOM spec
+ * moves a range's offsets to the start of the replaced data, so a selection
+ * dies. Holding the whole answer in a single node would rewrite it on every
+ * token and drop the user's selection each time. Split this way, React leaves
+ * a line whose string did not change untouched, so everything already written
+ * stays selectable while the answer continues. Only the line currently being
+ * written churns.
+ *
+ * Split results are kept unfiltered for the same reason: dropping the empty
+ * strings would renumber the keys as the answer grows.
+ */
+export function OrbitProse({ text }: { text: string }) {
+ const segments = text.split(/(\$\$[^$]+\$\$|\$[^$\n]+\$)/g);
+
+ return (
+
+ {segments.map((segment, index) => {
+ if (segment.startsWith('$$') && segment.endsWith('$$')) {
+ return (
+
+ {renderMath(segment.slice(2, -2).trim(), `b${index}`)}
+
+ );
+ }
+ if (segment.startsWith('$') && segment.endsWith('$') && segment.length > 2) {
+ return (
+
+ {renderMath(segment.slice(1, -1).trim(), `i${index}`)}
+
+ );
+ }
+ // The separators are kept as their own nodes; the bubble is pre-wrap,
+ // so the rendered text reads exactly as it did in one node.
+ return segment.split(/(\n)/).map((line, lineIndex) => (
+ {line}
+ ));
+ })}
+
+ );
+}
+
+// ── Timeline ────────────────────────────────────────────────────────────────
+
+export interface TimelineEntry {
+ kind: 'reasoning' | 'text' | 'tool';
+ key: string;
+ text?: string;
+ toolCallId?: string;
+ name?: string;
+ args?: unknown;
+ result?: unknown;
+}
+
+function parseJson(value: unknown): unknown {
+ if (typeof value !== 'string') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+}
+
+function messageText(content: PersistedMessage['content']): string {
+ if (typeof content === 'string') return content;
+ return content.map((part) => ('text' in part ? part.text : '')).join('');
+}
+
+/**
+ * Flattens a turn into the order the model produced it. Reasoning is placed
+ * before the text of the step it belongs to, which is the only ordering the
+ * persisted shape records (reasoning lives in its own field, not inline).
+ *
+ * Entry keys are ordinals within the turn rather than message ids, so that
+ * `buildStreamingTimeline` can produce the same key for the same entry. Only
+ * the final step's id is known while a run is in flight; the earlier steps get
+ * theirs when the turn is persisted, so any id-derived key would change under
+ * React the moment the run finishes and remount the prose, dropping a
+ * selection the user was making inside it.
+ */
+export function buildTimeline(sourceMessages: PersistedMessage[]): TimelineEntry[] {
+ const results = new Map();
+ for (const message of sourceMessages) {
+ if (message.role === 'tool' && message.toolCallId) {
+ results.set(message.toolCallId, parseJson(messageText(message.content)));
+ }
+ }
+
+ const entries: TimelineEntry[] = [];
+ const ordinals = { reasoning: 0, text: 0 };
+ for (const message of sourceMessages) {
+ if (message.role !== 'assistant') continue;
+
+ message.reasoningParts?.forEach((part) => {
+ if (part.text.trim()) {
+ entries.push({ kind: 'reasoning', key: `r${ordinals.reasoning++}`, text: part.text });
+ }
+ });
+
+ const text = messageText(message.content).trim();
+ if (text) {
+ entries.push({ kind: 'text', key: `t${ordinals.text++}`, text });
+ }
+
+ message.toolCalls?.forEach((call) => {
+ entries.push({
+ kind: 'tool',
+ key: call.id,
+ toolCallId: call.id,
+ name: call.function.name,
+ args: parseJson(call.function.arguments),
+ result: results.get(call.id),
+ });
+ });
+ }
+
+ return entries;
+}
+
+/** One step's parts, held back until the step's order is known. */
+interface StreamingStep {
+ reasoning: string[];
+ text: string;
+ tools: Extract[];
+}
+
+/**
+ * The same flattening for a run still in flight. Results are not known yet,
+ * which is what leaves the tool cards in their pending state.
+ *
+ * The parts arrive in the order the model emitted them, but a step's text and
+ * its tool calls are emitted in either order and a persisted message records no
+ * order between the two, so `buildTimeline` always puts the text first. The
+ * parts are grouped per step here and emitted in that same order, otherwise a
+ * step whose tool call came first would see its card and its prose swap places
+ * when the run finishes, remounting both.
+ *
+ * Ordinals are counted exactly as `buildTimeline` counts them, which is what
+ * keeps an entry's key stable across that handoff: the nth text entry here is
+ * the nth text entry there.
+ */
+export function buildStreamingTimeline(parts: ChatStreamingPart[]): TimelineEntry[] {
+ const steps: StreamingStep[] = [];
+ let step: StreamingStep | undefined;
+
+ for (const part of parts) {
+ // A step ends with its tool calls, so anything else after one starts the next.
+ if (!step || (part.kind !== 'tool_call' && step.tools.length > 0)) {
+ step = { reasoning: [], text: '', tools: [] };
+ steps.push(step);
+ }
+
+ if (part.kind === 'tool_call') step.tools.push(part);
+ else if (part.kind === 'reasoning') step.reasoning.push(part.text);
+ // Joined without a separator, as `messageText` joins a message's text parts.
+ else step.text += part.text;
+ }
+
+ const entries: TimelineEntry[] = [];
+ const ordinals = { reasoning: 0, text: 0 };
+ for (const { reasoning, text, tools } of steps) {
+ for (const thought of reasoning) {
+ if (thought.trim()) {
+ entries.push({ kind: 'reasoning', key: `r${ordinals.reasoning++}`, text: thought });
+ }
+ }
+
+ // Trimmed, as `buildTimeline` trims a message's text: the two strings have
+ // to match exactly or the prose element is replaced at the handoff.
+ if (text.trim()) {
+ entries.push({ kind: 'text', key: `t${ordinals.text++}`, text: text.trim() });
+ }
+
+ for (const tool of tools) {
+ entries.push({
+ kind: 'tool',
+ key: tool.toolCallId,
+ toolCallId: tool.toolCallId,
+ name: tool.name,
+ args: parseJson(tool.args),
+ });
+ }
+ }
+
+ return entries;
+}
+
+export function OrbitReasoning({ text, open }: { text: string; open?: boolean }) {
+ return (
+
+ Thinking
+
{text}
+
+ );
+}
+
+export function OrbitToolCard({ entry }: { entry: TimelineEntry }) {
+ // While a call streams its arguments are half-parsed JSON, so only show them
+ // once they parse into an object.
+ const args =
+ typeof entry.args === 'object' && entry.args !== null && !Array.isArray(entry.args)
+ ? (entry.args as Record)
+ : undefined;
+ const pending = entry.result === undefined;
+
+ return (
+
+ />
)}
- {renderedMessages.map((message) => {
- // Info notices (e.g. the abort "generation stopped" bubble) are
- // display-only system messages. Render a compact, centered pill —
- // no reasoning dropdown, markdown, feedback, or hover affordances.
- if (message.displayMode === 'info') {
- return (
-
-
- {getDisplayTextFromContent(message.content)}
-
-
- );
- }
-
- // The provisional bubble is deliberately kept out of
- // `chat-message-assistant` and `chat-message-content`: the E2E suites
- // wait on those test ids to mean "the answer is done", and a bubble
- // that appears with the first token would satisfy that wait
- // mid-stream. Only the attributes change when the answer is
- // persisted, so the elements themselves, and any selection inside
- // them, are untouched.
- const messageTestId = message.streaming
- ? 'chat-message-assistant-streaming'
- : `chat-message-${message.role}`;
- const contentTestId = message.streaming
- ? 'chat-message-content-streaming'
- : 'chat-message-content';
-
- return (
-
- )}
-
- {/* Accuracy disclaimer. Kept out of the empty chat so the first
- impression stays clean; once the user has sent a message it stays
- visible for the rest of the conversation. */}
- {features.inputDisclaimer && displayMessages.length > 0 && (
-
- {strings.input.disclaimer}
-
- )}
-
+
+
+ {/* Keep the disclaimer independent from Composer so a full input UI
+ replacement cannot accidentally remove configured safety copy. */}
+ {showInputDisclaimer && (
+
+ )}