From 5494b375d76f40518158420570a5651ec8ba5f0b Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 18:26:08 +0530 Subject: [PATCH 1/6] 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 93fdf49e..0689d02a 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 9a909d10..e3b3b8c1 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,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", @@ -950,36 +954,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 +1043,73 @@ 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 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 }, @@ -1534,11 +1595,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 +1732,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..603986b3 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"], @@ -45,6 +47,55 @@ 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"); + 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); +}); + test("in a group, keyboard decisions keep focus inside it", async ({ page, }) => { 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..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 03b46d53..c5b80eea 100644 --- a/stories/chat/full-chat.stories.tsx +++ b/stories/chat/full-chat.stories.tsx @@ -12,7 +12,6 @@ import { useCallback, useContext, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -29,11 +28,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, @@ -42,7 +36,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, @@ -64,21 +57,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 { @@ -107,10 +97,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, @@ -277,33 +264,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 = { @@ -389,133 +349,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, index, 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, position) => ( - - - - - ))} - + + decide(message.id, tool.toolCallId, decision) + } + onOpenImage={(part, index, opener) => openImage(part.id, index, opener)} + renderBlock={(part) => ( +
+

{blockTitle(part.block)}

+

{part.block.type}

+
)} -
+ /> ); } @@ -589,10 +443,10 @@ function ChatEntry({ /> )} {own ? ( - + ) : ( - + )} {footer === null ? null : {footer}} From 13c2eb2a328cff84759f36d6b4ad011347b3d56a Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:08 +0545 Subject: [PATCH 2/6] fix(chat): keep focus when a waiting call lapses or is dropped MessageParts moved focus only after a decision made through its own decide. When the server resolved a call to timeout or expired while focus was on its buttons, the call folded into its run, the buttons unmounted, and focus fell to the page. The same happened when a host recorded a decision by removing the call from parts. Track which waiting call's card holds focus from the root's focus and blur events, and give a call that stops waiting under focus the same handoff as a decided one: the next waiting call in a batch, else the run's trigger. For a call that is gone from parts, fall back to the group it stood in, then the group before it, then the first control in the message. Document the markup custom renderBatch and renderActivity must keep for the handoff, and that renderText takes precedence over renderMarkdown. Point the 0.3 upgrade rows at MessageParts, and name the image adapter's argument index. --- README.md | 68 ++++++------- e2e/fixtures.tsx | 44 ++++++-- e2e/tool-approval.spec.ts | 61 +++++++++++- src/ui/message-parts.test.tsx | 182 +++++++++++++++++++++++++++++++++- src/ui/message-parts.tsx | 176 ++++++++++++++++++++++++++------ 5 files changed, 453 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 0689d02a..07529823 100644 --- a/README.md +++ b/README.md @@ -336,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 e3b3b8c1..8ff5f6a0 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -936,12 +936,35 @@ 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 `MessageParts` then // folds into the run before it. function ToolApprovalScenario() { const { log, record } = useEventLog(); const [approval, setApproval] = useState(pendingApproval("approval-1")); + useApprovalLapse(({ status }) => + setApproval((current) => ({ ...current, status })), + ); const parts: PartTool[] = [ { kind: "tool-trace", @@ -1055,6 +1078,13 @@ function ToolBatchApprovalScenario() { 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", @@ -1092,17 +1122,9 @@ function ToolBatchApprovalScenario() { - setApprovals((current) => { - const approval = current[tool.toolCallId]; - if (approval === undefined) return current; - return { - ...current, - [tool.toolCallId]: { - ...approval, - status: decision.outcome, - scope: decision.scope, - }, - }; + update(tool.toolCallId, { + status: decision.outcome, + scope: decision.scope, }) } /> diff --git a/e2e/tool-approval.spec.ts b/e2e/tool-approval.spec.ts index 603986b3..3c38e7f3 100644 --- a/e2e/tool-approval.spec.ts +++ b/e2e/tool-approval.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { watchFocusOnBody } from "./focus.js"; @@ -162,3 +162,62 @@ 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( + waiting.getByRole("button", { name: "Approve", exact: true }), + ).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); +}); diff --git a/src/ui/message-parts.test.tsx b/src/ui/message-parts.test.tsx index 669f45fa..74af71f8 100644 --- a/src/ui/message-parts.test.tsx +++ b/src/ui/message-parts.test.tsx @@ -97,25 +97,69 @@ function settle(tool: PartTool, decision: ToolApprovalDecision): PartTool { }; } -function decideIn( +/** `parts` with one call, alone or in a batch, replaced by `change(call)`. */ +function updateCall( parts: readonly ConversationPart[], toolCallId: string, - decision: ToolApprovalDecision, + change: (tool: PartTool) => PartTool, ): readonly ConversationPart[] { return parts.map((part) => { if (part.kind === "tool-trace" && part.toolCallId === toolCallId) - return settle(part, decision); + return change(part); if (part.kind === "tool-batch") return { ...part, tools: part.tools.map((tool) => - tool.toolCallId === toolCallId ? settle(tool, decision) : 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, @@ -440,3 +484,133 @@ describe("MessageParts focus after a decision", () => { ).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?.textContent).toBe("Approve"); + 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("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 when the host drops the decided call", () => { + /** A host that records a decision by removing the call from `parts`. */ + function Dropper({ + initial, + }: { + readonly initial: readonly ConversationPart[]; + }) { + const [parts, setParts] = useState(initial); + return inMessage({ + 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?.textContent).toBe("Approve"); + expect( + container + .querySelector("[data-slot='tool-approval'][data-status='pending']") + ?.contains(active), + ).toBe(true); + }); + + 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 index 93300b86..fa7a0508 100644 --- a/src/ui/message-parts.tsx +++ b/src/ui/message-parts.tsx @@ -1,7 +1,7 @@ "use client"; import { useLayoutEffect, useMemo, useRef } from "react"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps, FocusEvent, ReactNode } from "react"; import { groupActivity, @@ -107,19 +107,76 @@ function findTool( 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[]; +}; + 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. + * 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)]; +} + +/** + * Where focus goes once a call has stopped waiting: 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. With no group, the first control in + * `element`. */ function focusTarget( element: Element, - group: PartsGroup, + group: PartsGroup | undefined, toolCallId: string, ): HTMLElement | null { - if (group.kind === "part" && group.part.kind === "tool-batch") { + 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) => @@ -139,6 +196,36 @@ function focusTarget( ); } +/** + * 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; + const inGroup = (group: PartsGroup | undefined): HTMLElement | null => { + const element = + group === undefined + ? null + : root.querySelector(`[data-part-key='${group.key}']`); + return element === null ? null : focusTarget(element, group, toolCallId); + }; + 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, toolCallId); +} + /** Records a decision on one call: the call, then the person's answer. */ export type MessagePartsDecide = ( tool: PartTool, @@ -168,7 +255,7 @@ export type MessagePartsProps = Omit< /** * 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. + * 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`. */ @@ -177,9 +264,17 @@ export type MessagePartsProps = Omit< * 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`. */ + /** + * 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`. */ + /** + * 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 ` + + ))} + + )} + />, + ); + 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[]) => @@ -520,7 +557,7 @@ describe("MessageParts focus when a call stops waiting with no decision", () => const first = lapseIn(initial, "one", "timeout"); render(props(first)); const next = document.activeElement as HTMLElement | null; - expect(next?.textContent).toBe("Approve"); + expect(next?.dataset["slot"]).toBe("tool-approval-card"); expect( container .querySelector("[data-slot='tool-approval'][data-status='pending']") @@ -556,15 +593,51 @@ describe("MessageParts focus when a call stops waiting with no decision", () => }); }); +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)), @@ -590,7 +663,7 @@ describe("MessageParts focus when the host drops the decided call", () => { const { container } = mount(); press(button(container, "Approve")); const active = document.activeElement as HTMLElement | null; - expect(active?.textContent).toBe("Approve"); + expect(active?.dataset["slot"]).toBe("tool-approval-card"); expect( container .querySelector("[data-slot='tool-approval'][data-status='pending']") @@ -598,6 +671,43 @@ describe("MessageParts focus when the host drops the decided call", () => { ).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( { + 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 next one after it (or the first); otherwise the run - * it folded into, by its trigger. With no group, the first control in - * `element`. + * 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, - toolCallId: string, + passed: ReadonlySet, ): 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 waiting = group.part.tools.filter(isAwaitingApproval); const next = Math.max( 0, - waiting.findIndex((index) => index > decided), + waiting.findIndex((tool) => !passed.has(tool.toolCallId)), ); const cards = element.querySelectorAll(WAITING); const card = cards[next] ?? cards[0]; - const button = card?.querySelector("button"); - if (button !== null && button !== undefined) return button; + 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", @@ -208,12 +236,15 @@ function handoffTarget( 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 => { - const element = - group === undefined - ? null - : root.querySelector(`[data-part-key='${group.key}']`); - return element === null ? null : focusTarget(element, group, toolCallId); + // 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); @@ -223,7 +254,7 @@ function handoffTarget( ? undefined : (groups.find((group) => group.key === was.group.key) ?? groupAt(groups, was.index - 1)); - return inGroup(near) ?? focusTarget(root, undefined, toolCallId); + return inGroup(near) ?? focusTarget(root, undefined, passed); } /** Records a decision on one call: the call, then the person's answer. */ @@ -299,8 +330,9 @@ export type MessagePartsProps = Omit< * moves. The handoff finds the waiting calls by their cards: render each * as a `[data-slot='tool-approval'][data-status='pending']` element, in * order, with a `