diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13642928..c18c8e36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: "Tool", "ToolApproval", "ToolGroup", "Reasoning", "Activity", "Message", "MessageFold", "MessageActions", "MessageReactions", "MessageReplies", "Attachments", "AttachmentDropZone", - "GeneratedImages", "Sources", "MediaViewer", + "GeneratedImages", "Sources", "MediaViewer", "MessageParts", ]; const missing = names.filter((n) => m[n] === undefined); if (missing.length) throw new Error("missing exports: " + missing.join(", ")); diff --git a/README.md b/README.md index 93fdf49e..07529823 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | `ConversationThread` | A reply thread headed by its parent message | | `Message` | One message, built from `MessageAvatar`, `MessageHeader`, `MessageContent`, `MessageSendStatus` and `MessageFooter` | | `MessageFold` | Folds a very long answer to its opening | +| `MessageParts` | A message's parts as their components, any kind replaceable; keeps focus when a decided call folds | | `MessageActions` | Row of actions under a message: `MessageAction`, `MessageActionMenu` | | `MessageReactions` | Emoji reactions with counts | | `MessageReplies` | Reply count and time that opens the thread | @@ -335,40 +336,40 @@ The old chat family is removed. The conversation family replaces it: a message i Removed exports and what replaces them: -| Removed | Use instead | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ChatThread`, `ChatThreadProps` | `Conversation` with `ConversationContent`, a `ConversationItem` per message and `ConversationScrollButton` | -| `AgentTurn`, `AgentTurnProps` | `Message` with `from="agent"`, `MessageAvatar`, `MessageHeader` and `MessageContent`; thinking and tool calls through `groupActivity` and `Activity` | -| `MessageBubble`, `MessageBubbleProps` | `Message` with `MessageContent`; `from="own"` draws the bubble | -| `PartsRenderer`, `PartsRendererProps` | Map `groupActivity(message.parts)` to `Activity`, `MessageContent`, `ToolApproval`, `ToolGroup`, `GeneratedImages`, `Sources` and `Attachments`; `block` parts are the host's to render | -| `toolTraceToBlockState` | No replacement: `Tool` reads `ToolTraceStatus` and the approval record directly | -| `ReasoningBlock`, `ReasoningBlockProps` | `Reasoning` (`ReasoningTrigger`, `ReasoningContent`) on a `PartReasoning` | -| `ToolBlock`, `ToolBlockProps`, `ToolBlockState` | `Tool` (`ToolHeader`, `ToolInput`, `ToolOutput`) on a `PartTool`; the state is its `ToolTraceStatus` | -| `ToolNarrative`, `ToolNarrativeProps` | `Tool`, or `ToolGroup` for calls that ran together | -| `ApprovalCard`, `ApprovalCardProps` | `ToolApproval` on a `PartTool` with a `ToolApprovalRecord`; `onApprove`, `onReject` and `onAllowAlways` become `onDecide(ToolApprovalDecision)`, and a busy `state` becomes `disabled` | -| `ApprovalRequest`, `ApprovalDetail` | The `PartTool` itself: `name` and `input`, with the host's own account of the action as `ToolApproval`'s children | -| `ApprovalState` | `ToolApprovalRecord`'s `status` for the outcome, `ToolApproval`'s `disabled` while a decision is recorded | -| `ChatInput`, `ChatInputProps` | `PromptInput` with `PromptInputTextarea` and `PromptInputSubmit`; `PromptInputToolbar` for controls under the text | -| `ChatAttachment` | `AttachmentData` for `Attachments`, `AttachmentItem` from `useAttachments` | -| `ChatComposer`, `ChatComposerProps` | `PromptInput`, with `ConversationEmptyState` for an empty conversation | -| `QuickReplyChips`, `QuickReply` | No replacement: suggested replies are the host's own buttons, for example inside `ConversationEmptyState` | -| `ChatPanel`, `ChatPanelHeader`, `ChatPanelFooter` | `Panel`, `PanelHeader` and `PanelContent` for a side panel; put `Conversation` and `PromptInput` in your own frame | -| `ChatMessage` | `ConversationMessage`: `role` becomes `author` (`ConversationParticipant`), `createdAt` becomes `at`, `status` is required | -| `ChatRole` | `ConversationParticipant`'s `kind` (`person` or `agent`); a system line is a `PartEvent`, shown with `ConversationNotice` | -| `MessageStatus` | `ConversationMessageStatus`, which adds `streaming` | -| `MessagePart` | `ConversationPart`, discriminated by `kind` instead of `type` | -| `TextPart`, `ReasoningPart`, `FilePart` | `PartText`, `PartReasoning`, `PartFile` | -| `ToolPart` | `PartTool`: `toolName` becomes `name`, `state` becomes `status`, and `label` moves to `ToolHeader`'s `label` | -| `ToolPartState` | `ToolTraceStatus`: `done` becomes `output-available` | -| `AgentIdentity` | `ConversationParticipant` with `kind: "agent"`; `tagline` becomes `role`, and `MessageAvatar` takes `initials` | -| `agentInitials` | `MessageAvatar`, which derives initials from `name` | -| `SubagentRun`, `SubagentState` | No replacement: the conversation model has no subagent type; show a helper's calls as `PartTool`s or a `PartToolBatch` | -| `ChatThreadSummary` | No replacement: a list of conversations is the host's own data | -| `messageText`, `reasoningText`, `toolParts` | No replacement: filter `message.parts` by `kind` | -| `isTurnWorking` | `parts.some((p) => p.kind === "tool-trace" && p.status === "running")` (any tool still running; `message.status === "streaming"` is the whole message) | -| `toolLabel` | No replacement: `ToolHeader` shows the raw tool name, after the `label` you pass | -| `ToolProviderMark` | `ProviderMark` with the provider read off the tool id (`name.split("__")[0]`, for ids shaped `provider__operation`) | -| `useDelayedAutofocus` | No replacement: focus the element from an effect once its entrance has finished | +| Removed | Use instead | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ChatThread`, `ChatThreadProps` | `Conversation` with `ConversationContent`, a `ConversationItem` per message and `ConversationScrollButton` | +| `AgentTurn`, `AgentTurnProps` | `Message` with `from="agent"`, `MessageAvatar`, `MessageHeader` and `MessageContent`, with `MessageParts` for the parts | +| `MessageBubble`, `MessageBubbleProps` | `Message` with `MessageContent`; `from="own"` draws the bubble | +| `PartsRenderer`, `PartsRendererProps` | `MessageParts` inside a `Message`; `renderBlock` for `block` parts | +| `toolTraceToBlockState` | No replacement: `Tool` reads `ToolTraceStatus` and the approval record directly | +| `ReasoningBlock`, `ReasoningBlockProps` | `Reasoning` (`ReasoningTrigger`, `ReasoningContent`) on a `PartReasoning` | +| `ToolBlock`, `ToolBlockProps`, `ToolBlockState` | `Tool` (`ToolHeader`, `ToolInput`, `ToolOutput`) on a `PartTool`; the state is its `ToolTraceStatus` | +| `ToolNarrative`, `ToolNarrativeProps` | `Tool`, or `ToolGroup` for calls that ran together | +| `ApprovalCard`, `ApprovalCardProps` | `ToolApproval` on a `PartTool` with a `ToolApprovalRecord`; `onApprove`, `onReject` and `onAllowAlways` become `onDecide(ToolApprovalDecision)`, and a busy `state` becomes `disabled` | +| `ApprovalRequest`, `ApprovalDetail` | The `PartTool` itself: `name` and `input`, with the host's own account of the action as `ToolApproval`'s children | +| `ApprovalState` | `ToolApprovalRecord`'s `status` for the outcome, `ToolApproval`'s `disabled` while a decision is recorded | +| `ChatInput`, `ChatInputProps` | `PromptInput` with `PromptInputTextarea` and `PromptInputSubmit`; `PromptInputToolbar` for controls under the text | +| `ChatAttachment` | `AttachmentData` for `Attachments`, `AttachmentItem` from `useAttachments` | +| `ChatComposer`, `ChatComposerProps` | `PromptInput`, with `ConversationEmptyState` for an empty conversation | +| `QuickReplyChips`, `QuickReply` | No replacement: suggested replies are the host's own buttons, for example inside `ConversationEmptyState` | +| `ChatPanel`, `ChatPanelHeader`, `ChatPanelFooter` | `Panel`, `PanelHeader` and `PanelContent` for a side panel; put `Conversation` and `PromptInput` in your own frame | +| `ChatMessage` | `ConversationMessage`: `role` becomes `author` (`ConversationParticipant`), `createdAt` becomes `at`, `status` is required | +| `ChatRole` | `ConversationParticipant`'s `kind` (`person` or `agent`); a system line is a `PartEvent`, shown with `ConversationNotice` | +| `MessageStatus` | `ConversationMessageStatus`, which adds `streaming` | +| `MessagePart` | `ConversationPart`, discriminated by `kind` instead of `type` | +| `TextPart`, `ReasoningPart`, `FilePart` | `PartText`, `PartReasoning`, `PartFile` | +| `ToolPart` | `PartTool`: `toolName` becomes `name`, `state` becomes `status`, and `label` moves to `ToolHeader`'s `label` | +| `ToolPartState` | `ToolTraceStatus`: `done` becomes `output-available` | +| `AgentIdentity` | `ConversationParticipant` with `kind: "agent"`; `tagline` becomes `role`, and `MessageAvatar` takes `initials` | +| `agentInitials` | `MessageAvatar`, which derives initials from `name` | +| `SubagentRun`, `SubagentState` | No replacement: the conversation model has no subagent type; show a helper's calls as `PartTool`s or a `PartToolBatch` | +| `ChatThreadSummary` | No replacement: a list of conversations is the host's own data | +| `messageText`, `reasoningText`, `toolParts` | No replacement: filter `message.parts` by `kind` | +| `isTurnWorking` | `parts.some((p) => p.kind === "tool-trace" && p.status === "running")` (any tool still running; `message.status === "streaming"` is the whole message) | +| `toolLabel` | No replacement: `ToolHeader` shows the raw tool name, after the `label` you pass | +| `ToolProviderMark` | `ProviderMark` with the provider read off the tool id (`name.split("__")[0]`, for ids shaped `provider__operation`) | +| `useDelayedAutofocus` | No replacement: focus the element from an effect once its entrance has finished | ## Upgrading from 0.1 diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 9a909d10..8ff5f6a0 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -44,9 +44,8 @@ import { PanelTabs, panelTabsHeaderClassName, useTabs, - Activity, - ToolApproval, - groupActivity, + MessageParts, + type ConversationPart, type PartTool, type ToolApprovalRecord, ToolGroup, @@ -927,17 +926,45 @@ function PanelTabsScenario() { ); } +/** A call's execution status once a decision is recorded on it. */ +function decidedStatus(approval: ToolApprovalRecord): PartTool["status"] { + if (approval.status === "pending") return "approval-requested"; + return approval.status === "approved" ? "output-available" : "output-denied"; +} + +function pendingApproval(id: string): ToolApprovalRecord { + return { id, status: "pending", scope: null, timeoutAt: null }; +} + +/** A call that stopped waiting with no decision, as the server reports it. */ +type Lapse = { + readonly toolCallId: string; + readonly status: "timeout" | "expired"; +}; + +/** + * Calls `onLapse` for each `approval-lapse` event on the window: the specs' + * stand-in for the server resolving an approval nobody decided, without + * moving focus the way a control on the page would. + */ +function useApprovalLapse(onLapse: (lapse: Lapse) => void) { + useEffect(() => { + const listener = (event: Event) => + onLapse((event as CustomEvent).detail); + window.addEventListener("approval-lapse", listener); + return () => window.removeEventListener("approval-lapse", listener); + }); +} + // A host's view of one agent turn: a finished draft, then a send waiting on a -// person. Deciding records the outcome on the call, which then folds into the -// run before it, the way a message renders `groupActivity`. +// person. Deciding records the outcome on the call, which `MessageParts` then +// folds into the run before it. function ToolApprovalScenario() { const { log, record } = useEventLog(); - const [approval, setApproval] = useState({ - id: "approval-1", - status: "pending", - scope: null, - timeoutAt: null, - }); + const [approval, setApproval] = useState(pendingApproval("approval-1")); + useApprovalLapse(({ status }) => + setApproval((current) => ({ ...current, status })), + ); const parts: PartTool[] = [ { kind: "tool-trace", @@ -950,36 +977,26 @@ function ToolApprovalScenario() { kind: "tool-trace", toolCallId: "send", name: "mail__send", - status: - approval.status === "pending" - ? "approval-requested" - : approval.status === "approved" - ? "output-available" - : "output-denied", + status: decidedStatus(approval), input: { to: "team" }, approval, }, ]; return ( <> - {groupActivity(parts).map((group) => - group.kind === "activity" ? ( - - ) : group.part.kind === "tool-trace" ? ( - { - record(`${decision.outcome}:${decision.scope}`); - setApproval((current) => ({ - ...current, - status: decision.outcome, - scope: decision.scope, - })); - }} - /> - ) : null, - )} + + { + record(`${decision.outcome}:${decision.scope}`); + setApproval((current) => ({ + ...current, + status: decision.outcome, + scope: decision.scope, + })); + }} + /> + {log} ); @@ -1049,6 +1066,72 @@ function ToolGroupApprovalScenario() { ); } +const BATCH_CALLS = ["notice", "totals", "calendar"] as const; + +// Three calls that ran at once after a finished read, two of them waiting on +// a person. Each decision is recorded on its call; the batch folds into the +// run once none is left waiting. +function ToolBatchApprovalScenario() { + const [approvals, setApprovals] = useState< + Record + >({ + notice: pendingApproval("approval-notice"), + calendar: pendingApproval("approval-calendar"), + }); + const update = (toolCallId: string, change: Partial) => + setApprovals((current) => { + const approval = current[toolCallId]; + if (approval === undefined) return current; + return { ...current, [toolCallId]: { ...approval, ...change } }; + }); + useApprovalLapse(({ toolCallId, status }) => update(toolCallId, { status })); + const parts: ConversationPart[] = [ + { + kind: "tool-trace", + toolCallId: "read", + name: "files__read", + status: "output-available", + output: "Read 3 files", + }, + { + kind: "tool-batch", + id: "batch", + tools: BATCH_CALLS.map((name): PartTool => { + const approval = approvals[name]; + return approval === undefined + ? { + kind: "tool-trace", + toolCallId: name, + name: `send__${name}`, + status: "output-available", + output: "Sent", + } + : { + kind: "tool-trace", + toolCallId: name, + name: `send__${name}`, + status: decidedStatus(approval), + input: { to: "team" }, + approval, + }; + }), + }, + ]; + return ( + + + update(tool.toolCallId, { + status: decision.outcome, + scope: decision.scope, + }) + } + /> + + ); +} + /** A long answer: 40 paragraphs of fixed-height lines, past the fold. */ const FOLD_PARAGRAPHS = Array.from( { length: 40 }, @@ -1534,11 +1617,7 @@ function ThreadMessage({ message }: { message: ConversationMessage }) { from={own ? "own" : "agent"} aria-label={`${message.author.name} ${message.id}`} > - (part.kind === "text" ? part.text : "")) - .join("")} - /> + ); } @@ -1675,6 +1754,7 @@ export const scenarios: Record ReactNode> = { "panel-tabs": () => , "tool-approval": () => , "tool-group-approval": () => , + "tool-batch-approval": () => , "message-fold": () => , "message-fold-settle": () => ( diff --git a/e2e/focus.ts b/e2e/focus.ts new file mode 100644 index 00000000..5c739dc4 --- /dev/null +++ b/e2e/focus.ts @@ -0,0 +1,37 @@ +import type { Page } from "@playwright/test"; + +type Watched = Window & { + __bodyFocus?: { frames: number; stop: () => void }; +}; + +/** + * Starts checking, every frame, whether focus has fallen to the page (no + * element, or ``). The returned function stops and resolves to how many + * frames it had: zero means a reader never saw focus lost. + */ +export async function watchFocusOnBody( + page: Page, +): Promise<() => Promise> { + await page.evaluate(() => { + const watched = window as Watched; + let frame = 0; + const state = { + frames: 0, + stop: () => cancelAnimationFrame(frame), + }; + const tick = () => { + const active = document.activeElement; + if (active === null || active === document.body) state.frames += 1; + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + watched.__bodyFocus = state; + }); + return () => + page.evaluate(() => { + const state = (window as Watched).__bodyFocus; + if (state === undefined) throw new Error("focus was not being watched"); + state.stop(); + return state.frames; + }); +} diff --git a/e2e/full-chat.spec.ts b/e2e/full-chat.spec.ts index 60f6661f..c99de18c 100644 --- a/e2e/full-chat.spec.ts +++ b/e2e/full-chat.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; +import { watchFocusOnBody } from "./focus.js"; + // The whole-chat composition lives in the story, so the spec drives the // static Storybook build the accessibility sweep also serves. const STORIES = "http://localhost:61000"; @@ -41,10 +43,16 @@ for (const [story, decision, outcome] of [ ); await expect(approval).toBeVisible(); await approval.getByRole("button", { name: decision, exact: true }).focus(); + const stop = await watchFocusOnBody(page); await page.keyboard.press("Enter"); // The call folds into the run before it, and its buttons go with it. await expect(approval).toHaveCount(0); + await expect + .poll(async () => (await focused(page)).slot) + .toBe("activity-trigger"); + // Not for a single frame did focus fall to the page. + expect(await stop()).toBe(0); const active = await focused(page); expect(active.body).toBe(false); expect(active.slot).toBe("activity-trigger"); diff --git a/e2e/tool-approval.spec.ts b/e2e/tool-approval.spec.ts index 239f02ff..f669baf3 100644 --- a/e2e/tool-approval.spec.ts +++ b/e2e/tool-approval.spec.ts @@ -1,4 +1,6 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +import { watchFocusOnBody } from "./focus.js"; const DECISIONS = [ ["Approve", "approved:once", "Approved once"], @@ -45,6 +47,57 @@ test("the decision works from the keyboard", async ({ page }) => { ); }); +for (const [button, key] of [ + ["Approve", "Enter"], + ["Approve always", "Space"], + ["Deny", "Enter"], +] as const) { + test(`${button} by keyboard never leaves focus on the page`, async ({ + page, + }) => { + await page.goto("/tool-approval"); + await page.getByRole("button", { name: button, exact: true }).focus(); + const stop = await watchFocusOnBody(page); + await page.keyboard.press(key); + + // The call folds into the run before it; focus goes to that run's line. + const run = page.getByRole("button", { name: "Worked through 2 steps" }); + await expect(run).toBeFocused(); + expect(await stop()).toBe(0); + }); +} + +test("in a batch, a keyboard decision moves to the next waiting call, then to the run", async ({ + page, +}) => { + await page.goto("/tool-batch-approval"); + const waiting = page.locator( + "[data-slot='tool-approval'][data-status='pending']", + ); + await expect(waiting).toHaveCount(2); + await waiting + .first() + .getByRole("button", { name: "Deny", exact: true }) + .focus(); + const stop = await watchFocusOnBody(page); + + await page.keyboard.press("Enter"); + await expect(waiting).toHaveCount(1); + await expect(waiting).toContainText("send__calendar"); + // The next call's card, not its Approve: Enter on it decides nothing. + await expect( + page.getByRole("region", { name: "Needs your approval send__calendar" }), + ).toBeFocused(); + + await waiting.getByRole("button", { name: "Approve", exact: true }).focus(); + await page.keyboard.press("Enter"); + await expect(waiting).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Worked through 4 steps" }), + ).toBeFocused(); + expect(await stop()).toBe(0); +}); + test("in a group, keyboard decisions keep focus inside it", async ({ page, }) => { @@ -111,3 +164,90 @@ test("in a group, keyboard decisions keep focus inside it", async ({ ); expect(await bodyFrames()).toBe(0); }); + +/** The server resolves a call nobody decided, the way the fixture hears it. */ +function lapse( + page: Page, + toolCallId: string, + status: "timeout" | "expired", +): Promise { + return page.evaluate( + (detail) => + void window.dispatchEvent(new CustomEvent("approval-lapse", { detail })), + { toolCallId, status }, + ); +} + +for (const status of ["timeout", "expired"] as const) { + test(`a call that ends ${status} under keyboard focus hands focus to the run`, async ({ + page, + }) => { + await page.goto("/tool-approval"); + await page.getByRole("button", { name: "Approve", exact: true }).focus(); + const stop = await watchFocusOnBody(page); + await lapse(page, "send", status); + + await expect( + page.locator("[data-slot='tool-approval'][data-status='pending']"), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Worked through 2 steps" }), + ).toBeFocused(); + expect(await stop()).toBe(0); + }); +} + +test("in a batch, calls that time out under focus hand it on, then to the run", async ({ + page, +}) => { + await page.goto("/tool-batch-approval"); + const waiting = page.locator( + "[data-slot='tool-approval'][data-status='pending']", + ); + await waiting + .first() + .getByRole("button", { name: "Deny", exact: true }) + .focus(); + const stop = await watchFocusOnBody(page); + + await lapse(page, "notice", "timeout"); + await expect(waiting).toHaveCount(1); + await expect( + page.getByRole("region", { name: "Needs your approval send__calendar" }), + ).toBeFocused(); + + await lapse(page, "calendar", "expired"); + await expect(waiting).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Worked through 4 steps" }), + ).toBeFocused(); + expect(await stop()).toBe(0); +}); + +test("in a batch, the next call lapsing after a keyboard decision hands focus to the run", async ({ + page, +}) => { + await page.goto("/tool-batch-approval"); + const waiting = page.locator( + "[data-slot='tool-approval'][data-status='pending']", + ); + await waiting + .first() + .getByRole("button", { name: "Deny", exact: true }) + .focus(); + const stop = await watchFocusOnBody(page); + + await page.keyboard.press("Enter"); + const next = page.getByRole("region", { + name: "Needs your approval send__calendar", + }); + await expect(next).toBeFocused(); + + // Focus stays where the decision moved it, on the card, as it lapses. + await lapse(page, "calendar", "expired"); + await expect(waiting).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Worked through 4 steps" }), + ).toBeFocused(); + expect(await stop()).toBe(0); +}); diff --git a/src/index.ts b/src/index.ts index 85c981e4..133c8e9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,7 @@ export * from "./ui/message-fold.js"; export * from "./ui/message-actions.js"; export * from "./ui/message-reactions.js"; export * from "./ui/message-replies.js"; +export * from "./ui/message-parts.js"; export * from "./ui/conversation-earlier.js"; export * from "./ui/conversation-map.js"; export * from "./ui/attachments.js"; diff --git a/src/ui/message-parts.test.tsx b/src/ui/message-parts.test.tsx new file mode 100644 index 00000000..48d8e9b9 --- /dev/null +++ b/src/ui/message-parts.test.tsx @@ -0,0 +1,835 @@ +import { act, useState } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { isAwaitingApproval } from "../lib/conversation-activity.js"; + +import type { + ConversationMessageStatus, + ConversationPart, + PartImage, + PartTool, + PartToolBatch, +} from "../lib/conversation.js"; +import { Message } from "./message.js"; +import { MessageParts, type MessagePartsProps } from "./message-parts.js"; +import { ToolApproval, type ToolApprovalDecision } from "./tool-approval.js"; + +type Mounted = { + container: HTMLElement; + render: (node: ReactElement) => void; + unmount: () => void; +}; + +const mounted: Mounted[] = []; + +function mount(node: ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + const handle = { + container, + render: (next: ReactElement) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; + mounted.push(handle); + return handle; +} + +afterEach(() => { + for (const handle of mounted.splice(0)) handle.unmount(); +}); + +function inMessage( + props: MessagePartsProps, + status: ConversationMessageStatus = "sent", +): ReactElement { + return ( + + + + ); +} + +function call(id: string, overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: id, + name: `tool__${id}`, + status: "output-available", + output: "ok", + ...overrides, + }; +} + +function waiting(id: string): PartTool { + return call(id, { + status: "approval-requested", + output: undefined, + input: { to: "team" }, + approval: { + id: `approval-${id}`, + status: "pending", + scope: null, + timeoutAt: null, + }, + }); +} + +/** The person's answer recorded on the call, as a host would. */ +function settle(tool: PartTool, decision: ToolApprovalDecision): PartTool { + if (tool.approval === undefined) throw new Error("no approval record"); + return { + ...tool, + status: + decision.outcome === "approved" ? "output-available" : "output-denied", + approval: { + ...tool.approval, + status: decision.outcome, + scope: decision.scope, + }, + }; +} + +/** `parts` with one call, alone or in a batch, replaced by `change(call)`. */ +function updateCall( + parts: readonly ConversationPart[], + toolCallId: string, + change: (tool: PartTool) => PartTool, +): readonly ConversationPart[] { + return parts.map((part) => { + if (part.kind === "tool-trace" && part.toolCallId === toolCallId) + return change(part); + if (part.kind === "tool-batch") + return { + ...part, + tools: part.tools.map((tool) => + tool.toolCallId === toolCallId ? change(tool) : tool, + ), + }; + return part; + }); +} + +function decideIn( + parts: readonly ConversationPart[], + toolCallId: string, + decision: ToolApprovalDecision, +): readonly ConversationPart[] { + return updateCall(parts, toolCallId, (tool) => settle(tool, decision)); +} + +/** The call stopped waiting with no decision, as the server records it. */ +function lapseIn( + parts: readonly ConversationPart[], + toolCallId: string, + status: "timeout" | "expired", +): readonly ConversationPart[] { + return updateCall(parts, toolCallId, (tool) => { + if (tool.approval === undefined) throw new Error("no approval record"); + return { + ...tool, + status: "output-denied", + approval: { ...tool.approval, status }, + }; + }); +} + +/** `parts` without one call, alone or in a batch. */ +function dropCall( + parts: readonly ConversationPart[], + toolCallId: string, +): readonly ConversationPart[] { + return parts.flatMap((part): ConversationPart[] => { + if (part.kind === "tool-trace") + return part.toolCallId === toolCallId ? [] : [part]; + if (part.kind === "tool-batch") + return [ + { + ...part, + tools: part.tools.filter((tool) => tool.toolCallId !== toolCallId), + }, + ]; + return [part]; + }); +} + +/** A host that records each decision as soon as it is made. */ +function Host({ + initial, + ...props +}: Omit & { + readonly initial: readonly ConversationPart[]; +}) { + const [parts, setParts] = useState(initial); + return inMessage({ + ...props, + parts, + onDecide: (tool, decision) => + setParts((current) => decideIn(current, tool.toolCallId, decision)), + }); +} + +function slots(container: HTMLElement): readonly string[] { + return [...container.querySelectorAll("[data-part-key] > [data-slot]")].map( + (element) => element.getAttribute("data-slot") ?? "", + ); +} + +function button(container: ParentNode, name: string): HTMLButtonElement { + const found = [...container.querySelectorAll("button")].find( + (element) => element.textContent === name, + ); + if (found === undefined) throw new Error(`expected a ${name} button`); + return found; +} + +function press(element: HTMLElement) { + act(() => element.focus()); + act(() => element.click()); +} + +const IMAGES: PartImage = { + kind: "image", + id: "images-1", + prompt: "A cover", + state: "done", + images: [ + { + id: "image-1", + mediaType: "image/svg+xml", + width: 100, + height: 100, + alt: "Cover", + base64: "PHN2Zy8+", + }, + ], +}; + +const EVERY_KIND: readonly ConversationPart[] = [ + { kind: "event", event: "Agent joined" }, + { kind: "reasoning", text: "Plan the work.", durationMs: 2_000 }, + call("read"), + { kind: "text", text: "Here is what I found." }, + { kind: "file", name: "a.csv", mediaType: "text/csv" }, + { kind: "file", name: "b.csv", mediaType: "text/csv" }, + IMAGES, + { kind: "sources", sources: [{ title: "Docs", url: "https://example.com" }] }, + { kind: "block", block: { type: "chart", data: { title: "Runs" } } }, + waiting("send"), +]; + +describe("MessageParts", () => { + test("maps each kind of part to its component, in order", () => { + const { container } = mount(inMessage({ parts: EVERY_KIND })); + expect(slots(container)).toEqual([ + "conversation-notice", + "activity", + "message-content", + "attachments", + "generated-images", + "sources", + "tool-approval", + ]); + expect(container.textContent).toContain("Thought for 2s · 1 step"); + // Consecutive files share one row of tiles. + expect( + container.querySelectorAll("[data-slot='attachments']"), + ).toHaveLength(1); + expect( + container.querySelector("[data-slot='attachments']")?.textContent, + ).toContain("b.csv"); + expect( + container + .querySelector("[data-slot='source-link']") + ?.getAttribute("href"), + ).toBe("https://example.com"); + }); + + test("a block shows nothing until the host renders it", () => { + const block = EVERY_KIND.filter((part) => part.kind === "block"); + const { container, render } = mount(inMessage({ parts: block })); + expect(container.querySelector("[data-part-key]")?.textContent).toBe(""); + render( + inMessage({ + parts: block, + renderBlock: (part) =>

{part.block.type}

, + }), + ); + expect(slots(container)).toEqual(["host-block"]); + }); + + test("text goes through renderMarkdown", () => { + const { container } = mount( + inMessage({ + parts: [{ kind: "text", text: "**bold**" }], + renderMarkdown: (text) => {text.slice(2, -2)}, + }), + ); + expect( + container.querySelector("[data-slot='message-content'] strong") + ?.textContent, + ).toBe("bold"); + }); + + test("an override replaces its kind; undefined keeps the default, null drops it", () => { + const { container } = mount( + inMessage({ + parts: EVERY_KIND, + renderText: (part) =>

{part.text}

, + renderEvent: () => undefined, + renderSources: () => null, + renderFiles: (files) =>

{files.length}

, + }), + ); + expect(slots(container)).toEqual([ + "conversation-notice", + "activity", + "host-text", + "host-files", + "generated-images", + "tool-approval", + ]); + expect( + container.querySelector("[data-slot='host-files']")?.textContent, + ).toBe("2"); + }); + + test("renderStep draws the steps of an opened run", () => { + const { container } = mount( + inMessage({ + parts: [call("read")], + renderStep: (step) => {step.kind}, + }), + ); + const trigger = container.querySelector( + "[data-slot='activity-trigger']", + ); + if (trigger === null) throw new Error("expected a trigger"); + act(() => trigger.click()); + expect( + container.querySelector("[data-slot='host-step']")?.textContent, + ).toBe("tool-trace"); + }); + + test("the last run is live while the message streams", () => { + const parts: readonly ConversationPart[] = [ + call("read"), + { kind: "text", text: "So far" }, + call("fetch", { status: "running", output: undefined }), + ]; + const { container, render } = mount(inMessage({ parts }, "streaming")); + const triggers = () => + [...container.querySelectorAll("[data-slot='activity-trigger']")].map( + (element) => element.textContent, + ); + expect(triggers()).toEqual(["Worked through 1 step", "tool__fetch…"]); + render(inMessage({ parts, streaming: false }, "streaming")); + expect(triggers()).toEqual([ + "Worked through 1 step", + "Worked through 1 step", + ]); + }); + + test("choosing an image reports its part and index", () => { + const opened: [string, number][] = []; + const { container } = mount( + inMessage({ + parts: [IMAGES], + onOpenImage: (part, index) => opened.push([part.id, index]), + }), + ); + const open = container.querySelector( + "[data-slot='generated-image-open']", + ); + if (open === null) throw new Error("expected an open control"); + act(() => open.click()); + expect(opened).toEqual([["images-1", 0]]); + }); +}); + +describe("MessageParts focus after a decision", () => { + const RUN_THEN_WAIT: readonly ConversationPart[] = [ + call("draft"), + waiting("send"), + ]; + + for (const name of ["Approve", "Approve always", "Deny"]) { + test(`${name} folds the call and focus lands on the run's trigger`, () => { + const { container } = mount(); + press(button(container, name)); + expect(container.querySelector("[data-slot='tool-approval']")).toBeNull(); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("activity-trigger"); + expect(active?.textContent).toContain("Worked through 2 steps"); + }); + } + + test("a call with no run before it lands on the run it starts", () => { + const { container } = mount( + , + ); + press(button(container, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("activity-trigger"); + expect(active?.textContent).toContain("Worked through 1 step"); + }); + + test("in a batch, focus moves to the next call still waiting, then to the run", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), call("two"), waiting("three")], + }; + const { container } = mount(); + const cards = () => [ + ...container.querySelectorAll( + "[data-slot='tool-approval'][data-status='pending']", + ), + ]; + expect(cards()).toHaveLength(2); + + press(button(container, "Deny")); + expect(cards()).toHaveLength(1); + const next = document.activeElement as HTMLElement | null; + expect(next?.dataset["slot"]).toBe("tool-approval-card"); + expect(cards()[0]?.contains(next)).toBe(true); + + press(button(container, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("activity-trigger"); + expect(active?.textContent).toContain("Worked through 4 steps"); + }); + + test("the next waiting call wraps to the first when the decided one was last", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two")], + }; + const { container } = mount(); + const last = [ + ...container.querySelectorAll("[data-slot='tool-approval']"), + ].at(-1); + if (last === undefined) throw new Error("expected two cards"); + press(button(last, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("tool-approval-card"); + expect( + container + .querySelector("[data-slot='tool-approval'][data-status='pending']") + ?.contains(active), + ).toBe(true); + }); + + test("a host's batch card without the card slot gets its first button", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two")], + }; + const { container } = mount( + ( +
    + {part.tools.filter(isAwaitingApproval).map((tool) => ( +
  1. + +
  2. + ))} +
+ )} + />, + ); + press(button(container, "Run tool__one")); + expect(document.activeElement).toBe(button(container, "Run tool__two")); + }); + + test("a decision the host records later moves focus once it shows", () => { + const decisions: ToolApprovalDecision[] = []; + const props = (parts: readonly ConversationPart[]) => + inMessage({ parts, onDecide: (_, decision) => decisions.push(decision) }); + const { container, render } = mount(props(RUN_THEN_WAIT)); + const approve = button(container, "Approve"); + press(approve); + // Not recorded yet: the buttons stay and keep focus. + render(props(RUN_THEN_WAIT)); + expect(document.activeElement).toBe(approve); + + const [decision] = decisions; + if (decision === undefined) throw new Error("expected a decision"); + render(props(decideIn(RUN_THEN_WAIT, "send", decision))); + expect( + (document.activeElement as HTMLElement | null)?.dataset["slot"], + ).toBe("activity-trigger"); + }); + + test("focus that went somewhere else stays there", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + const decisions: ToolApprovalDecision[] = []; + const props = (parts: readonly ConversationPart[]) => + inMessage({ parts, onDecide: (_, decision) => decisions.push(decision) }); + const { container, render } = mount(props(RUN_THEN_WAIT)); + press(button(container, "Approve")); + act(() => outside.focus()); + const [decision] = decisions; + if (decision === undefined) throw new Error("expected a decision"); + render(props(decideIn(RUN_THEN_WAIT, "send", decision))); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); + + test("a host's renderApproval gets the handoff through decide", () => { + const { container } = mount( + ( + + )} + />, + ); + press(button(container, "Go")); + expect( + (document.activeElement as HTMLElement | null)?.dataset["slot"], + ).toBe("activity-trigger"); + }); +}); + +describe("MessageParts focus when a call stops waiting with no decision", () => { + const RUN_THEN_WAIT: readonly ConversationPart[] = [ + call("draft"), + waiting("send"), + ]; + const props = (parts: readonly ConversationPart[]) => + inMessage({ parts, onDecide: () => {} }); + const activeSlot = () => + (document.activeElement as HTMLElement | null)?.dataset["slot"]; + + for (const status of ["timeout", "expired"] as const) { + test(`a call that ends ${status} with focus on its buttons hands focus to the run`, () => { + const { container, render } = mount(props(RUN_THEN_WAIT)); + act(() => button(container, "Approve").focus()); + render(props(lapseIn(RUN_THEN_WAIT, "send", status))); + expect(container.querySelector("[data-slot='tool-approval']")).toBeNull(); + expect(activeSlot()).toBe("activity-trigger"); + expect(document.activeElement?.textContent).toContain( + "Worked through 2 steps", + ); + }); + } + + test("in a batch, focus moves to the next call still waiting, then to the run", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), call("two"), waiting("three")], + }; + const initial: readonly ConversationPart[] = [call("draft"), batch]; + const { container, render } = mount(props(initial)); + act(() => button(container, "Deny").focus()); + const first = lapseIn(initial, "one", "timeout"); + render(props(first)); + const next = document.activeElement as HTMLElement | null; + expect(next?.dataset["slot"]).toBe("tool-approval-card"); + expect( + container + .querySelector("[data-slot='tool-approval'][data-status='pending']") + ?.contains(next), + ).toBe(true); + + render(props(lapseIn(first, "three", "expired"))); + expect(activeSlot()).toBe("activity-trigger"); + expect(document.activeElement?.textContent).toContain( + "Worked through 4 steps", + ); + }); + + test("a batch that times out with focus on its header hands focus to the run", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [call("one"), waiting("two")], + }; + const initial: readonly ConversationPart[] = [call("draft"), batch]; + const { container, render } = mount(props(initial)); + const header = container.querySelector( + "[data-slot='tool-group-header']", + ); + if (header === null) throw new Error("expected a tool group header"); + act(() => header.focus()); + expect(document.activeElement).toBe(header); + + render(props(lapseIn(initial, "two", "timeout"))); + expect(container.querySelector("[data-slot='tool-group']")).toBeNull(); + expect(activeSlot()).toBe("activity-trigger"); + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement?.textContent).toContain( + "Worked through 3 steps", + ); + }); + + test("after a decision in a batch, the next call lapsing under focus hands focus to the run", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two")], + }; + const initial: readonly ConversationPart[] = [call("draft"), batch]; + const decisions: ToolApprovalDecision[] = []; + const recording = (parts: readonly ConversationPart[]) => + inMessage({ parts, onDecide: (_, decision) => decisions.push(decision) }); + const { container, render } = mount(recording(initial)); + press(button(container, "Deny")); + const [decision] = decisions; + if (decision === undefined) throw new Error("expected a decision"); + const decided = decideIn(initial, "one", decision); + render(recording(decided)); + const next = document.activeElement as HTMLElement | null; + expect(next?.dataset["slot"]).toBe("tool-approval-card"); + expect( + container.querySelector( + "[data-slot='tool-approval'][data-status='pending']", + )?.textContent, + ).toContain("tool__two"); + + render(recording(lapseIn(decided, "two", "expired"))); + expect(activeSlot()).toBe("activity-trigger"); + expect(document.activeElement?.textContent).toContain( + "Worked through 3 steps", + ); + }); + + test("a decision the host never records does not hide a later call lapsing under focus", () => { + const initial: readonly ConversationPart[] = [ + call("draft"), + waiting("first"), + { kind: "text", text: "And then" }, + waiting("second"), + ]; + const { container, render } = mount(props(initial)); + const cards = () => [ + ...container.querySelectorAll("[data-slot='tool-approval']"), + ]; + const [first, second] = cards(); + if (first === undefined || second === undefined) + throw new Error("expected two cards"); + // The host's request fails: the first call stays waiting for good. + press(button(first, "Approve")); + act(() => button(second, "Approve").focus()); + render(props(lapseIn(initial, "second", "timeout"))); + // The lapsed call folds into a run of its own, after the text. + const triggers = [ + ...container.querySelectorAll("[data-slot='activity-trigger']"), + ]; + expect(activeSlot()).toBe("activity-trigger"); + expect(triggers.indexOf(document.activeElement as Element)).toBe(1); + }); + + test("an earlier decision recorded as the focused call lapses hands focus from where it was", () => { + const initial: readonly ConversationPart[] = [ + call("draft"), + waiting("first"), + { kind: "text", text: "And then" }, + waiting("second"), + ]; + const { container, render } = mount(props(initial)); + const [first, second] = [ + ...container.querySelectorAll("[data-slot='tool-approval']"), + ]; + if (first === undefined || second === undefined) + throw new Error("expected two cards"); + press(button(first, "Approve")); + act(() => button(second, "Approve").focus()); + // One update records the first decision and lapses the second call. + const decided = decideIn(initial, "first", { + outcome: "approved", + scope: "once", + }); + render(props(lapseIn(decided, "second", "timeout"))); + const triggers = [ + ...container.querySelectorAll("[data-slot='activity-trigger']"), + ]; + expect(activeSlot()).toBe("activity-trigger"); + expect(triggers.indexOf(document.activeElement as Element)).toBe(1); + }); + + test("focus that left the buttons for the page is not pulled back", () => { + const { container, render } = mount(props(RUN_THEN_WAIT)); + act(() => button(container, "Approve").focus()); + // A click on blank page: focus leaves to nothing. + act(() => (document.activeElement as HTMLElement).blur()); + render(props(lapseIn(RUN_THEN_WAIT, "send", "timeout"))); + expect(document.activeElement).toBe(document.body); + }); + + test("focus elsewhere in the message is not moved", () => { + const { container, render } = mount(props(RUN_THEN_WAIT)); + act(() => button(container, "Approve").focus()); + const trigger = container.querySelector( + "[data-slot='activity-trigger']", + ); + if (trigger === null) throw new Error("expected a trigger"); + act(() => trigger.focus()); + render(props([call("draft")])); + expect(document.activeElement).toBe(trigger); + }); +}); + +describe("MessageParts focus while a call waits", () => { + const props = (parts: readonly ConversationPart[]) => + inMessage({ parts, onDecide: () => {} }); + + test("a waiting call keeps focus when the host drops a part before it", () => { + const initial: readonly ConversationPart[] = [ + { kind: "text", text: "Sending now." }, + waiting("send"), + ]; + const { container, render } = mount(props(initial)); + const approve = button(container, "Approve"); + act(() => approve.focus()); + render(props(initial.slice(1))); + expect(document.activeElement).toBe(approve); + }); + + test("a waiting batch keeps focus when the host drops a part before it", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two")], + }; + const initial: readonly ConversationPart[] = [ + { kind: "event", event: "Agent joined" }, + batch, + ]; + const { container, render } = mount(props(initial)); + const deny = button(container, "Deny"); + act(() => deny.focus()); + render(props(initial.slice(1))); + expect(document.activeElement).toBe(deny); + }); +}); + +describe("MessageParts focus when the host drops the decided call", () => { + /** A host that records a decision by removing the call from `parts`. */ + function Dropper({ + initial, + ...props + }: Omit & { + readonly initial: readonly ConversationPart[]; + }) { + const [parts, setParts] = useState(initial); + return inMessage({ + ...props, + parts, + onDecide: (tool) => + setParts((current) => dropCall(current, tool.toolCallId)), + }); + } + + test("focus lands on the run the call sat under", () => { + const { container } = mount( + , + ); + press(button(container, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("activity-trigger"); + expect(active?.textContent).toContain("Worked through 1 step"); + }); + + test("in a batch, focus moves to the call still waiting", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two")], + }; + const { container } = mount(); + press(button(container, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("tool-approval-card"); + expect( + container + .querySelector("[data-slot='tool-approval'][data-status='pending']") + ?.contains(active), + ).toBe(true); + }); + + test("in a host's renderBatch, focus moves to the call that waited after the dropped one", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch-1", + tools: [waiting("one"), waiting("two"), waiting("three")], + }; + const { container } = mount( + ( +
    + {part.tools.map((tool) => ( +
  1. + decide(tool, decision)} + /> +
  2. + ))} +
+ )} + />, + ); + const cards = () => [ + ...container.querySelectorAll( + "[data-slot='tool-approval'][data-status='pending']", + ), + ]; + const middle = cards()[1]; + if (middle === undefined) throw new Error("expected three cards"); + press(button(middle, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("tool-approval-card"); + expect(cards()[1]?.contains(active)).toBe(true); + expect(cards()[1]?.textContent).toContain("tool__three"); + }); + + test("with nothing before it, focus goes to the message's first control", () => { + const { container } = mount( + , + ); + press(button(container, "Approve")); + const active = document.activeElement as HTMLElement | null; + expect(active?.dataset["slot"]).toBe("activity-trigger"); + expect(active?.textContent).toContain("Worked through 1 step"); + }); +}); diff --git a/src/ui/message-parts.tsx b/src/ui/message-parts.tsx new file mode 100644 index 00000000..ea78a70e --- /dev/null +++ b/src/ui/message-parts.tsx @@ -0,0 +1,620 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef } from "react"; +import type { ComponentProps, FocusEvent, ReactNode } from "react"; + +import { + groupActivity, + isAwaitingApproval, + type ActivityStep, + type ConversationPartGroup, +} from "../lib/conversation-activity.js"; +import type { + ConversationPart, + PartBlock, + PartEvent, + PartFile, + PartImage, + PartSources, + PartText, + PartTool, + PartToolBatch, +} from "../lib/conversation.js"; +import { cn } from "../lib/utils.js"; +import { + Activity, + ActivityContent, + ActivityTrigger, + type ActivityContentProps, +} from "./activity.js"; +import { + Attachment, + AttachmentInfo, + AttachmentPreview, + Attachments, +} from "./attachments.js"; +import { ConversationNotice } from "./conversation-divider.js"; +import { GeneratedImages } from "./generated-images.js"; +import { MessageContent, useMessageContext } from "./message.js"; +import { Source, Sources } from "./sources.js"; +import { ToolApproval, type ToolApprovalDecision } from "./tool-approval.js"; +import { ToolGroup } from "./tool-group.js"; + +/** + * A message's parts as `MessageParts` lays them out: each run of thinking and + * tool work as one `activity`, each run of files as one `files` row, and + * every other part on its own. A tool call or batch that stands as a `part` + * is one waiting on a person. + */ +type PartsGroup = + | { + readonly kind: "activity"; + readonly key: string; + readonly steps: readonly ActivityStep[]; + } + | { + readonly kind: "files"; + readonly key: string; + readonly files: readonly PartFile[]; + } + | { + readonly kind: "part"; + readonly key: string; + readonly part: Exclude; + }; + +function groupParts(parts: readonly ConversationPart[]): readonly PartsGroup[] { + const groups: PartsGroup[] = []; + // The open run of files; the group pushed for it holds this same array. + let files: PartFile[] | null = null; + for (const group of groupActivity(parts)) { + if (group.kind === "part" && group.part.kind === "file") { + if (files === null) { + files = []; + groups.push({ kind: "files", key: group.key, files }); + } + files.push(group.part); + continue; + } + files = null; + if (group.kind === "activity") groups.push(group); + else if (group.part.kind !== "file") + groups.push({ kind: "part", key: partKey(group), part: group.part }); + } + return groups; +} + +/** + * A waiting call is keyed by its id, not by where it stands, so a part + * dropped before it does not remount its buttons and drop focus with them. + */ +function partKey(group: ConversationPartGroup & { kind: "part" }): string { + const { part } = group; + if (part.kind === "tool-trace") return `tool-trace-${part.toolCallId}`; + if (part.kind === "tool-batch") return `tool-batch-${part.id}`; + return group.key; +} + +function toolsOf(part: ConversationPart): readonly PartTool[] { + if (part.kind === "tool-trace") return [part]; + if (part.kind === "tool-batch") return part.tools; + return []; +} + +function partsIn(group: PartsGroup): readonly ConversationPart[] { + switch (group.kind) { + case "activity": + return group.steps; + case "files": + return group.files; + case "part": + return [group.part]; + } +} + +function findTool( + parts: readonly ConversationPart[], + toolCallId: string, +): PartTool | undefined { + return parts.flatMap(toolsOf).find((tool) => tool.toolCallId === toolCallId); +} + +/** The group holding a call, and the index in `parts` of the part holding it. */ +function placeOf( + groups: readonly PartsGroup[], + toolCallId: string, +): { readonly group: PartsGroup; readonly index: number } | undefined { + let index = 0; + for (const group of groups) + for (const part of partsIn(group)) { + if (toolsOf(part).some((tool) => tool.toolCallId === toolCallId)) + return { group, index }; + index += 1; + } + return undefined; +} + +/** The group laid out from the part at `index` in `parts`. */ +function groupAt( + groups: readonly PartsGroup[], + index: number, +): PartsGroup | undefined { + if (index < 0) return undefined; + let end = 0; + return groups.find((group) => { + end += partsIn(group).length; + return index < end; + }); +} + +/** + * A call whose buttons may go away with focus on them: one just decided, or + * one waiting with focus inside. `groups` is the layout it was last seen in. + */ +type Leaving = { + readonly toolCallId: string; + readonly groups: readonly PartsGroup[]; +}; + +/** + * Focus inside a waiting batch, but not on a waiting card. `key` is the + * group's, so a fold that re-keys the run is visible. `toolCallId` locates + * that run the same way a lapsed call does. + */ +type FoldingBatch = Leaving & { + readonly key: string; +}; + +const WAITING = "[data-slot='tool-approval'][data-status='pending']"; + +/** + * The waiting call whose card holds `element`: a call standing on its own, + * or, in a batch, the waiting card it is in, counted in order. + */ +function waitingCallAt( + element: Element, + groups: readonly PartsGroup[], +): PartTool | undefined { + const holder = element.closest("[data-part-key]"); + const key = holder?.getAttribute("data-part-key"); + const group = groups.find((candidate) => candidate.key === key); + if (holder === null || group?.kind !== "part") return undefined; + const waiting = toolsOf(group.part).filter(isAwaitingApproval); + if (group.part.kind === "tool-trace") return waiting[0]; + const card = element.closest(WAITING); + if (card === null) return undefined; + return waiting[[...holder.querySelectorAll(WAITING)].indexOf(card)]; +} + +/** + * A waiting tool batch that `element` is inside, other than on a waiting + * card (`waitingCallAt` watches those). The header and the rows are real + * controls and sit outside that card; when the last waiting call folds the + * batch into a run, they unmount with it. + */ +function foldingBatchAt( + element: Element, + groups: readonly PartsGroup[], +): FoldingBatch | null { + if (element.closest(WAITING) !== null) return null; + const holder = element.closest("[data-part-key]"); + if (holder === null) return null; + const key = holder.getAttribute("data-part-key"); + const group = groups.find((candidate) => candidate.key === key); + if (group?.kind !== "part" || group.part.kind !== "tool-batch") return null; + const tool = group.part.tools.find(isAwaitingApproval); + if (tool === undefined) return null; + return { key: group.key, toolCallId: tool.toolCallId, groups }; +} + +/** + * The ids of the calls up to and including `toolCallId` in the batch that + * held it in `groups`: the ones the next waiting call does not come from. + */ +function callsThrough( + groups: readonly PartsGroup[], + toolCallId: string, +): ReadonlySet { + const group = placeOf(groups, toolCallId)?.group; + if (group?.kind !== "part") return new Set(); + const ids = toolsOf(group.part).map((tool) => tool.toolCallId); + return new Set(ids.slice(0, ids.indexOf(toolCallId) + 1)); +} + +/** + * Where focus goes once a call has stopped waiting: in a batch that still + * has calls waiting, the card of the first one not in `passed` (or else of + * the first one); otherwise the run it folded into, by its trigger. With no + * group, the first control in `element`. + * + * The card, not its Approve button, as `ToolGroup` does: a held Enter + * repeats onto whatever has focus, and would decide a call the person never + * chose. A host's card without the slot gets its first button. + */ +function focusTarget( + element: Element, + group: PartsGroup | undefined, + passed: ReadonlySet, +): HTMLElement | null { + if (group?.kind === "part" && group.part.kind === "tool-batch") { + const waiting = group.part.tools.filter(isAwaitingApproval); + const next = Math.max( + 0, + waiting.findIndex((tool) => !passed.has(tool.toolCallId)), + ); + const cards = element.querySelectorAll(WAITING); + const card = cards[next] ?? cards[0]; + const target = + card?.querySelector("[data-slot='tool-approval-card']") ?? + card?.querySelector("button"); + if (target !== null && target !== undefined) return target; + } + return element.querySelector( + "[data-slot='activity-trigger'], [data-slot='tool-group-header'], button", + ); +} + +/** + * Where focus goes, inside `root`, once the `leaving` call stopped waiting. + * A call the host dropped from `parts` hands focus to its group if that + * still stands (a batch with other calls), else to the group before where + * it stood, else to the first control in `root`. + */ +function handoffTarget( + root: Element, + groups: readonly PartsGroup[], + leaving: Leaving, +): HTMLElement | null { + const { toolCallId } = leaving; + // Taken from where the call last stood, as a dropped call is gone from + // `groups`. + const passed = callsThrough(leaving.groups, toolCallId); + const inGroup = (group: PartsGroup | undefined): HTMLElement | null => { + // Matched by attribute, not a selector: a key holds a host's call id. + const element = [...root.children].find( + (child) => child.getAttribute("data-part-key") === group?.key, + ); + return element === undefined ? null : focusTarget(element, group, passed); + }; + const here = placeOf(groups, toolCallId); + if (here !== undefined) return inGroup(here.group); + const was = placeOf(leaving.groups, toolCallId); + const near = + was === undefined + ? undefined + : (groups.find((group) => group.key === was.group.key) ?? + groupAt(groups, was.index - 1)); + return inGroup(near) ?? focusTarget(root, undefined, passed); +} + +/** Records a decision on one call: the call, then the person's answer. */ +export type MessagePartsDecide = ( + tool: PartTool, + decision: ToolApprovalDecision, +) => void; + +export type MessagePartsProps = Omit< + ComponentProps<"div">, + "children" | "ref" +> & { + /** The message's parts, in order. */ + readonly parts: readonly ConversationPart[]; + /** + * Whether the message is still arriving: its last run then shows its live + * line. Defaults to the enclosing `Message`'s `streaming` status. + */ + readonly streaming?: boolean | undefined; + /** + * A person's decision on a call waiting for one. A fast double click can + * call it twice, so record decisions idempotently by approval id. + */ + readonly onDecide?: MessagePartsDecide | undefined; + /** A generated image was chosen; `opener` is where focus should go back to. */ + readonly onOpenImage?: + | ((part: PartImage, index: number, opener: HTMLElement) => void) + | undefined; + /** + * Renders a text part's text, passed to `MessageContent` as `render`: say, + * markdown through the host's own renderer and sanitiser. Without it the + * text is plain. `renderText` takes precedence over it. + */ + readonly renderMarkdown?: ((text: string) => ReactNode) | undefined; + /** Renders one step of an opened run. Defaults to `Reasoning`, `Tool` or `ToolGroup`. */ + readonly renderStep?: ActivityContentProps["renderStep"]; + /* + * The overrides below each replace one kind of part. Each returns what to + * show, `null` for nothing, or `undefined` to keep the default. + */ + /** + * A text part. Defaults to `MessageContent`. Takes precedence over + * `renderMarkdown`; return `undefined` to fall back to it. + */ + readonly renderText?: ((part: PartText) => ReactNode) | undefined; + /** + * A run of thinking and tool work. Defaults to `Activity`. When a call + * folds into the run with focus on it, focus moves to the run's first + * `[data-slot='activity-trigger']` or `