From 7437dff5efe2a25657a35bf61e6c00f191cf5972 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 18:26:08 +0530 Subject: [PATCH] feat(chat): add MessageParts to render a message's parts Every host mapped ConversationPart to components by hand and re-implemented the focus handoff when a decided call folds into the run before it. MessageParts groups parts with groupActivity and renders each kind as its component (Activity, ToolApproval, ToolGroup, MessageContent, Attachments tiles, GeneratedImages, Sources, ConversationNotice), with a render prop per kind to replace any of them. After a decision folds a call away, focus moves to the run's trigger, or to the next waiting call in a batch, instead of falling to the page. The full chat and activity stories and the e2e fixtures use it in place of their own mappings; the e2e specs check that focus never sits on for a frame after a keyboard decision. --- README.md | 1 + e2e/fixtures.tsx | 138 ++++++--- e2e/focus.ts | 37 +++ e2e/full-chat.spec.ts | 8 + e2e/tool-approval.spec.ts | 51 ++++ src/index.ts | 1 + src/ui/message-parts.test.tsx | 442 +++++++++++++++++++++++++++++ src/ui/message-parts.tsx | 406 ++++++++++++++++++++++++++ stories/chat/activity.stories.tsx | 60 ++-- stories/chat/full-chat.stories.tsx | 186 ++---------- 10 files changed, 1088 insertions(+), 242 deletions(-) create mode 100644 e2e/focus.ts create mode 100644 src/ui/message-parts.test.tsx create mode 100644 src/ui/message-parts.tsx diff --git a/README.md b/README.md index faf7e31f..52afbaf5 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 | diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 11f5f914..66b9d026 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -42,9 +42,8 @@ import { PanelTabAdd, PanelTabs, useTabs, - Activity, - ToolApproval, - groupActivity, + MessageParts, + type ConversationPart, type PartTool, type ToolApprovalRecord, Message, @@ -738,17 +737,22 @@ 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 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")); const parts: PartTool[] = [ { kind: "tool-trace", @@ -761,41 +765,98 @@ 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} ); } +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 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 ( + + + setApprovals((current) => { + const approval = current[tool.toolCallId]; + if (approval === undefined) return current; + return { + ...current, + [tool.toolCallId]: { + ...approval, + 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 }, @@ -1275,11 +1336,7 @@ function ThreadMessage({ message }: { message: ConversationMessage }) { from={own ? "own" : "agent"} aria-label={`${message.author.name} ${message.id}`} > - (part.kind === "text" ? part.text : "")) - .join("")} - /> + ); } @@ -1410,6 +1467,7 @@ export const scenarios: Record ReactNode> = { "prompt-input-toolbar": () => , "panel-tabs": () => , "tool-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 3f8bf3a9..877c04bb 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"; @@ -34,10 +36,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 ca026268..4d270bc8 100644 --- a/e2e/tool-approval.spec.ts +++ b/e2e/tool-approval.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import { watchFocusOnBody } from "./focus.js"; + const DECISIONS = [ ["Approve", "approved:once", "Approved once"], ["Approve always", "approved:always", "Approved always"], @@ -44,3 +46,52 @@ test("the decision works from the keyboard", async ({ page }) => { "approved:always", ); }); + +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"); + await expect( + waiting.getByRole("button", { name: "Approve", exact: true }), + ).toBeFocused(); + + 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); +}); diff --git a/src/index.ts b/src/index.ts index e3900b71..4d3390dc 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..669f45fa --- /dev/null +++ b/src/ui/message-parts.test.tsx @@ -0,0 +1,442 @@ +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 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, + }, + }; +} + +function decideIn( + parts: readonly ConversationPart[], + toolCallId: string, + decision: ToolApprovalDecision, +): readonly ConversationPart[] { + return parts.map((part) => { + if (part.kind === "tool-trace" && part.toolCallId === toolCallId) + return settle(part, decision); + if (part.kind === "tool-batch") + return { + ...part, + tools: part.tools.map((tool) => + tool.toolCallId === toolCallId ? settle(tool, decision) : tool, + ), + }; + 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?.textContent).toBe("Approve"); + 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?.textContent).toBe("Approve"); + expect( + container + .querySelector("[data-slot='tool-approval'][data-status='pending']") + ?.contains(active), + ).toBe(true); + }); + + 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"); + }); +}); diff --git a/src/ui/message-parts.tsx b/src/ui/message-parts.tsx new file mode 100644 index 00000000..93300b86 --- /dev/null +++ b/src/ui/message-parts.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef } from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { + groupActivity, + isAwaitingApproval, + type ActivityStep, +} 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: group.key, part: group.part }); + } + return groups; +} + +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); +} + +const WAITING = "[data-slot='tool-approval'][data-status='pending']"; + +/** + * Where focus goes once a decided call has moved: in a batch that still has + * calls waiting, the next one after it (or the first); otherwise the run it + * folded into, by its trigger. + */ +function focusTarget( + element: Element, + group: PartsGroup, + toolCallId: string, +): HTMLElement | null { + if (group.kind === "part" && group.part.kind === "tool-batch") { + const { tools } = group.part; + const decided = tools.findIndex((tool) => tool.toolCallId === toolCallId); + const waiting = tools.flatMap((tool, index) => + isAwaitingApproval(tool) ? [index] : [], + ); + const next = Math.max( + 0, + waiting.findIndex((index) => index > decided), + ); + const cards = element.querySelectorAll(WAITING); + const card = cards[next] ?? cards[0]; + const button = card?.querySelector("button"); + if (button !== null && button !== undefined) return button; + } + return element.querySelector( + "[data-slot='activity-trigger'], [data-slot='tool-group-header'], button", + ); +} + +/** 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. + */ + 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`. */ + readonly renderText?: ((part: PartText) => ReactNode) | undefined; + /** A run of thinking and tool work. Defaults to `Activity`. */ + readonly renderActivity?: + | (( + steps: readonly ActivityStep[], + state: { readonly streaming: boolean }, + ) => ReactNode) + | undefined; + /** + * A call waiting on a person. Defaults to `ToolApproval`. Use `decide` + * rather than `onDecide`, so focus is looked after when the call moves. + */ + readonly renderApproval?: + | (( + part: PartTool, + decide: (decision: ToolApprovalDecision) => void, + ) => ReactNode) + | undefined; + /** + * A batch with a call waiting on a person. Defaults to `ToolGroup`. Use + * `decide` rather than `onDecide`, so focus is looked after when the call + * moves. + */ + readonly renderBatch?: + | ((part: PartToolBatch, decide: MessagePartsDecide) => ReactNode) + | undefined; + /** + * A generative-UI block. Its `data` is the host's to parse, so without + * this a block shows nothing. + */ + readonly renderBlock?: ((part: PartBlock) => ReactNode) | undefined; + /** A run of consecutive file parts. Defaults to `Attachments` tiles. */ + readonly renderFiles?: + | ((files: readonly PartFile[]) => ReactNode) + | undefined; + /** A system line. Defaults to a `ConversationNotice` of its `event`. */ + readonly renderEvent?: ((part: PartEvent) => ReactNode) | undefined; + /** Generated images. Defaults to `GeneratedImages`. */ + readonly renderImages?: ((part: PartImage) => ReactNode) | undefined; + /** An answer's sources. Defaults to numbered `Sources`. */ + readonly renderSources?: ((part: PartSources) => ReactNode) | undefined; +}; + +/** + * A message's parts, each as its component: runs of thinking and tool work + * fold into one `Activity` each (`groupActivity`), a call waiting on a person + * stands out as a `ToolApproval` (or inside its `ToolGroup`), text is a + * `MessageContent`, files are `Attachments` tiles, images + * `GeneratedImages`, sources `Sources` and events a `ConversationNotice`. + * Any kind can be replaced through its `render…` prop. Place it inside a + * `Message`. + * + * Deciding a call folds it into the run before it, so its buttons go away. + * When that leaves focus on the page, focus moves to the run's trigger, or, + * in a batch with calls still waiting, to the next one's buttons. A host's + * own `renderApproval` or `renderBatch` gets the same through its `decide`. + */ +export function MessageParts({ + parts, + streaming: streamingProp, + onDecide, + onOpenImage, + renderMarkdown, + renderStep, + renderText, + renderActivity, + renderApproval, + renderBatch, + renderBlock, + renderFiles, + renderEvent, + renderImages, + renderSources, + className, + ...props +}: MessagePartsProps) { + const message = useMessageContext(); + const streaming = streamingProp ?? message?.status === "streaming"; + const groups = useMemo(() => groupParts(parts), [parts]); + const root = useRef(null); + // The call last decided here, until its decision shows in `parts`. + const decided = useRef(null); + + useLayoutEffect(() => { + const toolCallId = decided.current; + if (toolCallId === null) return; + const tool = findTool(parts, toolCallId); + // The host has not recorded it yet; the buttons are still there. + if (tool !== undefined && isAwaitingApproval(tool)) return; + decided.current = null; + const active = document.activeElement; + if (active !== null && active !== document.body) return; + const group = groups.find((candidate) => + partsIn(candidate).some((part) => + toolsOf(part).some((held) => held.toolCallId === toolCallId), + ), + ); + const element = + group === undefined + ? null + : root.current?.querySelector(`[data-part-key='${group.key}']`); + if (group === undefined || element === null || element === undefined) + return; + focusTarget(element, group, toolCallId)?.focus(); + }); + + const decide: MessagePartsDecide = (tool, decision) => { + decided.current = tool.toolCallId; + onDecide?.(tool, decision); + }; + + const show = (group: PartsGroup, index: number): ReactNode => { + switch (group.kind) { + case "activity": { + const live = streaming && index === groups.length - 1; + const custom = renderActivity?.(group.steps, { streaming: live }); + if (custom !== undefined) return custom; + return ( + + {renderStep === undefined ? undefined : ( + <> + + + + )} + + ); + } + case "files": { + const custom = renderFiles?.(group.files); + if (custom !== undefined) return custom; + return ( + + {group.files.map((file, position) => ( + + + + + ))} + + ); + } + case "part": + return showPart(group.part); + } + }; + + const showPart = (part: Exclude): ReactNode => { + switch (part.kind) { + case "text": { + const custom = renderText?.(part); + if (custom !== undefined) return custom; + return ; + } + case "tool-trace": { + const decideThis = (decision: ToolApprovalDecision) => + decide(part, decision); + const custom = renderApproval?.(part, decideThis); + if (custom !== undefined) return custom; + return ; + } + case "tool-batch": { + const custom = renderBatch?.(part, decide); + if (custom !== undefined) return custom; + return ; + } + case "block": + return renderBlock?.(part) ?? null; + case "event": { + const custom = renderEvent?.(part); + if (custom !== undefined) return custom; + return {part.event}; + } + case "image": { + const custom = renderImages?.(part); + if (custom !== undefined) return custom; + return ( + onOpenImage(part, image, opener) + } + /> + ); + } + case "sources": { + const custom = renderSources?.(part); + if (custom !== undefined) return custom; + return ( + + {part.sources.map((source, position) => ( + + {source.title} + + ))} + + ); + } + case "reasoning": + // Unreachable: thinking always folds into a run. + return null; + } + }; + + return ( +
+ {groups.map((group, index) => ( + // Laid out as its contents; there to find a group's controls by key. +
+ {show(group, index)} +
+ ))} +
+ ); +} diff --git a/stories/chat/activity.stories.tsx b/stories/chat/activity.stories.tsx index 7f5f0957..a013fc44 100644 --- a/stories/chat/activity.stories.tsx +++ b/stories/chat/activity.stories.tsx @@ -12,8 +12,9 @@ import type { } from "../../src/lib/conversation.js"; import { Activity } from "../../src/ui/activity.js"; import { Button } from "../../src/ui/button.js"; +import { Message } from "../../src/ui/message.js"; +import { MessageParts } from "../../src/ui/message-parts.js"; import { Reasoning } from "../../src/ui/reasoning.js"; -import { ToolApproval } from "../../src/ui/tool-approval.js"; import { toolStorm } from "./samples/conversations.js"; export default { title: "Chat / Activity" }; @@ -31,23 +32,14 @@ function firstRun(parts: readonly ConversationPart[]): readonly ActivityStep[] { return []; } -/** A message's parts as they render: runs fold, text stays. */ -function Parts({ parts }: { readonly parts: readonly ConversationPart[] }) { - return ( -
- {groupActivity(parts).map((group) => - group.kind === "activity" ? ( - - ) : group.part.kind === "text" ? ( -

{group.part.text}

- ) : null, - )} -
- ); -} - /** The tool storm turn: each run folded to its summary between the text. */ -export const ToolStorm = () => ; +export const ToolStorm = () => ( +
+ + + +
+); /** A 41-step run opened: the latest ten, with a control for the rest. */ export const Opened = () => ( @@ -141,7 +133,8 @@ export const ReasoningStates = () => ( /** * A call waiting on a person stands outside the fold. Once decided, it - * joins the run before it, and the run's steps show the outcome. + * joins the run before it, the run's steps show the outcome, and focus + * moves to the run's trigger. */ export const ApprovalFolds = () => { const [approval, setApproval] = useState({ @@ -174,24 +167,19 @@ export const ApprovalFolds = () => { }, ]; return ( -
- {groupActivity(parts).map((group) => - group.kind === "activity" ? ( - - ) : group.part.kind === "tool-trace" ? ( - - setApproval({ - ...approval, - status: decision.outcome, - scope: decision.scope, - }) - } - /> - ) : null, - )} +
+ + + setApproval({ + ...approval, + status: decision.outcome, + scope: decision.scope, + }) + } + /> +
); }; diff --git a/stories/chat/full-chat.stories.tsx b/stories/chat/full-chat.stories.tsx index 0d91eeda..d0258d91 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -11,7 +11,6 @@ import { createContext, useContext, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -28,11 +27,6 @@ import { useMediaViewer } from "../../src/hooks/use-media-viewer.js"; import { panelTabIds, useTabs } from "../../src/hooks/use-tabs.js"; import type { AttachmentRejection } from "../../src/lib/attachment-limits.js"; import type { PartBlock, PartFile } from "../../src/lib/conversation.js"; -import { - groupActivity, - type ActivityStep, - type ConversationPartGroup, -} from "../../src/lib/conversation-activity.js"; import type { ConversationMessage, ConversationPart, @@ -41,7 +35,6 @@ import type { PartTool, } from "../../src/lib/conversation.js"; import type { PromptToken } from "../../src/lib/prompt-tokens.js"; -import { Activity } from "../../src/ui/activity.js"; import { AttachmentDropZone } from "../../src/ui/attachment-drop-zone.js"; import { Attachment, @@ -63,21 +56,18 @@ import { type ConversationMapTurn, } from "../../src/ui/conversation-map.js"; import { ConversationThread } from "../../src/ui/conversation-thread.js"; -import { - GeneratedImages, - generatedImageViewerItems, -} from "../../src/ui/generated-images.js"; +import { generatedImageViewerItems } from "../../src/ui/generated-images.js"; import { MediaViewer } from "../../src/ui/media-viewer.js"; import { Message, MessageAvatar, - MessageContent, MessageFooter, MessageHeader, type MessageFrom, } from "../../src/ui/message.js"; import { MessageAction, MessageActions } from "../../src/ui/message-actions.js"; import { MessageFold } from "../../src/ui/message-fold.js"; +import { MessageParts } from "../../src/ui/message-parts.js"; import { MessageReactions } from "../../src/ui/message-reactions.js"; import { MessageReplies } from "../../src/ui/message-replies.js"; import { @@ -106,10 +96,7 @@ import { type PromptInputModelOption, type PromptInputModeOption, } from "../../src/ui/prompt-input-toolbar.js"; -import { Source, Sources } from "../../src/ui/sources.js"; import type { ToolApprovalDecision } from "../../src/ui/tool-approval.js"; -import { ToolApproval } from "../../src/ui/tool-approval.js"; -import { ToolGroup } from "../../src/ui/tool-group.js"; import { SAMPLE_NOW, assistant, @@ -275,33 +262,6 @@ function turnsOf( return turns; } -/** The ids of the tool calls a step or part carries. */ -function toolCallIds(part: ConversationPart): readonly string[] { - if (part.kind === "tool-trace") return [part.toolCallId]; - if (part.kind === "tool-batch") - return part.tools.map((tool) => tool.toolCallId); - return []; -} - -function holdsCall(group: ConversationPartGroup, toolCallId: string): boolean { - const parts: readonly (ConversationPart | ActivityStep)[] = - group.kind === "activity" ? group.steps : [group.part]; - return parts.some((part) => toolCallIds(part).includes(toolCallId)); -} - -/** - * Where focus goes once a decided call has moved: the first call still - * waiting in the same batch, else the run's trigger or the batch's header. - */ -function nearestControl(group: Element): HTMLElement | null { - return ( - group.querySelector("[data-slot='tool-approval'] button") ?? - group.querySelector( - "[data-slot='activity-trigger'], [data-slot='tool-group-header']", - ) - ); -} - /** The person's answer recorded on the call; an approved call has then run. */ function settle(tool: PartTool, decision: ToolApprovalDecision): PartTool { const approval = { @@ -382,133 +342,27 @@ function useChatActions(): ChatActions { } /** - * A message's parts as they render: runs of thinking and tool work fold, - * calls waiting on a person stand out with their buttons, and the rest - * shows as itself. - * - * Deciding a call folds it into the run before it, so its buttons unmount. - * Focus would fall to the page; it goes to the run's trigger instead. + * A message's parts as they render. Blocks are the host's to draw; this one + * shows each as a small card with its title. */ -function MessageParts({ message }: { readonly message: ConversationMessage }) { +function ChatParts({ message }: { readonly message: ConversationMessage }) { const { decide, openImage } = useChatActions(); const own = message.author.id === you.id; - const streaming = message.status === "streaming"; - const groups = groupActivity(message.parts); - const root = useRef(null); - const decided = useRef(null); - - useLayoutEffect(() => { - const toolCallId = decided.current; - if (toolCallId === null) return; - decided.current = null; - const active = document.activeElement; - if (active !== null && active !== document.body) return; - const group = groups.find((candidate) => holdsCall(candidate, toolCallId)); - const element = - group === undefined - ? null - : root.current?.querySelector(`[data-group-key='${group.key}']`); - if (element !== null && element !== undefined) - nearestControl(element)?.focus(); - }); - - const onDecide = (toolCallId: string, decision: ToolApprovalDecision) => { - decided.current = toolCallId; - decide(message.id, toolCallId, decision); - }; - const files = message.parts.filter( - (part): part is PartFile => part.kind === "file", - ); - return ( -
- {groups.map((group, index) => { - if (group.kind === "activity") - return ( - - ); - const { part } = group; - switch (part.kind) { - case "text": - return ( - - ); - case "tool-trace": - return ( - onDecide(part.toolCallId, decision)} - /> - ); - case "tool-batch": - return ( - - onDecide(tool.toolCallId, decision) - } - /> - ); - case "image": - return ( - openImage(part.id, image, opener)} - /> - ); - case "sources": - return ( - - {part.sources.map((source, position) => ( - - {source.title} - - ))} - - ); - case "block": - return ( -
-

{blockTitle(part.block)}

-

{part.block.type}

-
- ); - default: - return null; - } - })} - {files.length === 0 ? null : ( - - {files.map((file) => ( - - - - - ))} - + + decide(message.id, tool.toolCallId, decision) + } + onOpenImage={(part, index, opener) => openImage(part.id, index, opener)} + renderBlock={(part) => ( +
+

{blockTitle(part.block)}

+

{part.block.type}

+
)} -
+ /> ); } @@ -582,10 +436,10 @@ function ChatEntry({ /> )} {own ? ( - + ) : ( - + )} {footer === null ? null : {footer}}