From 48df39bea4e7af6eb04cd9e1cec189997ebdd900 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:55:25 +0530 Subject: [PATCH 01/10] feat(agent): add Tool, ToolApproval, ToolGroup, Reasoning and Activity Tool renders every ToolTraceStatus with an icon and a word, and its input and output come through a render prop. ToolApproval asks a person to approve once, approve always or deny, and renders a settled decision from the approval record itself. ToolGroup shows calls that ran at the same time, Reasoning shows thinking with its duration, and Activity folds both into one summary line showing the latest ten steps. The stories axe sweep now measures text contrast itself wherever axe cannot: it hit-tests each text box to find the ground actually under it, composites colour and opacity layers, reads gradient and fill colours, and reports anything it cannot resolve as unmeasured rather than passing it. Browser fixtures pin the cases that must never pass. --- CONTRIBUTING.md | 3 +- e2e/contrast-dom.ts | 438 ++++++++++++++++++++++++++++++ e2e/contrast-fixtures.spec.ts | 157 +++++++++++ e2e/contrast.test.ts | 387 ++++++++++++++++++++++++++ e2e/contrast.ts | 286 +++++++++++++++++++ e2e/fixtures.tsx | 64 +++++ e2e/stories-a11y.spec.ts | 43 ++- e2e/tool-approval.spec.ts | 46 ++++ package.json | 2 +- src/index.ts | 5 + src/lib/tool-display.test.ts | 87 ++++++ src/lib/tool-display.ts | 40 +++ src/ui/activity.test.tsx | 317 +++++++++++++++++++++ src/ui/activity.tsx | 332 ++++++++++++++++++++++ src/ui/reasoning.tsx | 171 ++++++++++++ src/ui/tool-approval.test.tsx | 274 +++++++++++++++++++ src/ui/tool-approval.tsx | 297 ++++++++++++++++++++ src/ui/tool-group.tsx | 163 +++++++++++ src/ui/tool.test.tsx | 360 ++++++++++++++++++++++++ src/ui/tool.tsx | 349 ++++++++++++++++++++++++ stories/chat/activity.stories.tsx | 197 ++++++++++++++ stories/chat/tool.stories.tsx | 289 ++++++++++++++++++++ 22 files changed, 4304 insertions(+), 3 deletions(-) create mode 100644 e2e/contrast-dom.ts create mode 100644 e2e/contrast-fixtures.spec.ts create mode 100644 e2e/contrast.test.ts create mode 100644 e2e/contrast.ts create mode 100644 e2e/tool-approval.spec.ts create mode 100644 src/lib/tool-display.test.ts create mode 100644 src/lib/tool-display.ts create mode 100644 src/ui/activity.test.tsx create mode 100644 src/ui/activity.tsx create mode 100644 src/ui/reasoning.tsx create mode 100644 src/ui/tool-approval.test.tsx create mode 100644 src/ui/tool-approval.tsx create mode 100644 src/ui/tool-group.tsx create mode 100644 src/ui/tool.test.tsx create mode 100644 src/ui/tool.tsx create mode 100644 stories/chat/activity.stories.tsx create mode 100644 stories/chat/tool.stories.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65d35f1b..0b511806 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,8 @@ bun run check Contributors sign the [CLA](CLA.md) on their first PR; the CLA bot explains how. `bun run test:e2e` checks the built package: every `exports` target exists and the -root entry loads under Node with every optional peer blocked, then packs the +root entry loads under Node with every optional peer blocked, and unit-tests the +contrast maths the stories sweep uses (`e2e/contrast.ts`), then packs the package into a Vite host and runs the Playwright specs in `e2e/` against it. Each scenario is a route in `e2e/fixtures.tsx`. The same run builds the Storybook stories and fails on any serious or critical axe violation in any story. Run `bun run build` first, and diff --git a/e2e/contrast-dom.ts b/e2e/contrast-dom.ts new file mode 100644 index 00000000..614363b7 --- /dev/null +++ b/e2e/contrast-dom.ts @@ -0,0 +1,438 @@ +import type { Page } from "@playwright/test"; + +import type { + ClippedBackground, + ContrastLayer, + ContrastSample, + PaintEntry, +} from "./contrast.js"; + +// Reads, in the browser, what e2e/contrast.ts judges: the colours, layers and +// hit-test stacks of text axe leaves "incomplete". + +/** + * The glyph colours and every layer from each element out to the root, + * resolved by the browser, with the hit-test stack at points across the + * text so paint outside those layers is found, and so each point is + * measured only on the boxes under it. `judged` lists the selectors of every + * node axe's color-contrast rule judged on the page. `null` where an element or + * ancestor paints in a way the layers cannot follow: a filter, a blend mode, + * or a clipped background that is not one plain gradient. A mask is not + * followed: like axe, the layers take it as fully opaque. + */ +export async function contrastSamples( + page: Page, + selectors: readonly string[], + judged: readonly string[], +): Promise { + return page.evaluate( + ([targets, judgedSelectors]) => { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (context === null) return targets.map(() => null); + // Paints the computed colour, whatever syntax it computes to + // (color(srgb …) from color-mix, oklab, rgb), and reads it back as sRGB. + const resolve = (color: string): [number, number, number, number] => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0, a = 0] = context.getImageData( + 0, + 0, + 1, + 1, + ).data; + return [r, g, b, a / 255]; + }; + const masked = (style: CSSStyleDeclaration) => + style.maskImage !== "none" || + (style.getPropertyValue("-webkit-mask-image") || "none") !== "none"; + // Nothing on the way to the root fades, filters, blends or masks it. + const unfaded = (element: Element) => { + for ( + let node: Element | null = element; + node; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if ( + Number(style.opacity) < 1 || + style.filter !== "none" || + style.mixBlendMode !== "normal" || + masked(style) + ) + return false; + } + return true; + }; + const clipsText = (style: CSSStyleDeclaration) => + style.backgroundClip === "text" || + style.getPropertyValue("-webkit-background-clip") === "text"; + const REPLACED = new Set([ + "IMG", + "CANVAS", + "VIDEO", + "IFRAME", + "EMBED", + "OBJECT", + "INPUT", + "TEXTAREA", + "SELECT", + ]); + const visibleLine = (width: string, style: string, color: string) => + Number.parseFloat(width) > 0 && + style !== "none" && + style !== "hidden" && + resolve(color)[3] > 0; + // Hit-testing still finds what `opacity: 0` hides. + const transparent = (element: Element) => { + for ( + let node: Element | null = element; + node; + node = node.parentElement + ) + if (Number(getComputedStyle(node).opacity) === 0) return true; + return false; + }; + // Text with a glyph to paint, not only spaces or zero-width characters. + const glyphs = (text: string | null) => + (text ?? "").replace(/[\s\u200b-\u200d\u2060\ufeff]/g, "") !== ""; + const ownGlyphs = (element: Element) => + [...element.childNodes].some( + (child) => + child.nodeType === Node.TEXT_NODE && glyphs(child.textContent), + ); + // An element's own box at a hit point, its pseudo-elements aside. + const paintOf = (element: Element): PaintEntry["paint"] => { + if (transparent(element)) return "none"; + const style = getComputedStyle(element); + if ( + resolve(style.backgroundColor)[3] === 1 && + style.backgroundClip === "border-box" && + unfaded(element) + ) + return "solid"; + const sides = ["top", "right", "bottom", "left"] as const; + const paints = + element instanceof SVGElement || + REPLACED.has(element.tagName) || + ownGlyphs(element) || + resolve(style.backgroundColor)[3] > 0 || + style.backgroundImage !== "none" || + style.backdropFilter !== "none" || + style.boxShadow.includes("inset") || + sides.some((side) => + visibleLine( + style.getPropertyValue(`border-${side}-width`), + style.getPropertyValue(`border-${side}-style`), + style.getPropertyValue(`border-${side}-color`), + ), + ) || + visibleLine( + style.outlineWidth, + style.outlineStyle, + style.outlineColor, + ) || + (style.display === "list-item" && style.listStyleType !== "none"); + return paints ? "some" : "none"; + }; + // Whether either pseudo-element paints anything at all. + const pseudoPaints = (element: Element) => + !transparent(element) && + ["::before", "::after"].some((pseudo) => { + const style = getComputedStyle(element, pseudo); + if ( + style.content === "none" || + style.content === "normal" || + style.display === "none" || + style.visibility === "hidden" + ) + return false; + return ( + style.content !== '""' || + resolve(style.backgroundColor)[3] > 0 || + style.backgroundImage !== "none" || + style.backdropFilter !== "none" || + style.boxShadow !== "none" || + resolve(style.borderColor)[3] > 0 || + style.outlineStyle !== "none" + ); + }); + // Rectangles of the element's own text, or its box for a form field. + const textRects = (element: Element): DOMRect[] => { + const rects: DOMRect[] = []; + for (const child of element.childNodes) { + if (child.nodeType !== Node.TEXT_NODE || !glyphs(child.textContent)) + continue; + const range = document.createRange(); + range.selectNodeContents(child); + rects.push(...range.getClientRects()); + } + return rects.length > 0 ? rects : [...element.getClientRects()]; + }; + // Every element hit-testable, pseudo-elements included; then only + // pseudo-elements, which hit as the element that owns them. + const everything = document.createElement("style"); + everything.textContent = + "*, *::before, *::after { pointer-events: auto !important; }"; + const pseudoOnly = document.createElement("style"); + pseudoOnly.textContent = + "* { pointer-events: none !important; } *::before, *::after { pointer-events: auto !important; }"; + + // Elements whose text axe's color-contrast judges in its own right. + const judgedElements = new Set( + judgedSelectors.flatMap((selector) => { + const element = document.querySelector(selector); + return element === null ? [] : [element]; + }), + ); + + // The paint stacks at points across the text, and every other element + // drawing glyphs under it. + const pointsOf = ( + element: Element, + depth: ReadonlyMap, + ): { points: PaintEntry[][]; drawers: Set } => { + // The text element is hit wherever its glyphs are, so only its box + // tells whether its own paint is under a point; that box is exact + // only while nothing on the way to the root rotates, scales or + // skews it. + const turned = (() => { + for ( + let node: Element | null = element; + node; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if (style.rotate !== "none" || style.scale !== "none") return true; + if (style.transform === "none") continue; + const matrix = new DOMMatrix(style.transform); + matrix.m41 = 0; + matrix.m42 = 0; + matrix.m43 = 0; + if (!matrix.isIdentity) return true; + } + return false; + })(); + const own = getComputedStyle(element); + const paintsOwnBox = + (!clipsText(own) && + (resolve(own.backgroundColor)[3] > 0 || + own.backgroundImage !== "none")) || + own.backdropFilter !== "none"; + const scrolled: [Element, number, number][] = []; + for (let node = element.parentElement; node; node = node.parentElement) + scrolled.push([node, node.scrollLeft, node.scrollTop]); + const [x, y] = [window.scrollX, window.scrollY]; + // Text an overflow scroller holds out of view shows once scrolled to. + element.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: "instant", + }); + const spots: [number, number][] = []; + for (const rect of textRects(element)) + for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) + for (const fy of [0.3, 0.5, 0.7]) + spots.push([ + rect.left + rect.width * fx, + rect.top + rect.height * fy, + ]); + document.head.append(everything); + const stacks = spots.map(([px, py]) => + document.elementsFromPoint(px, py), + ); + everything.remove(); + document.head.append(pseudoOnly); + const pseudos = spots.map(([px, py]) => + document.elementsFromPoint(px, py), + ); + pseudoOnly.remove(); + const drawers = new Set(); + const points = stacks.map((stack, index) => { + const [px = 0, py = 0] = spots[index] ?? []; + // Hit, and its own box holds the point: text overflowing its own + // box still hits it. Client rects bound a rotated or skewed box + // from outside, so for one they can say "holds" where it does not. + const holds = (node: Element) => + [...node.getClientRects()].some( + (rect) => + px >= rect.left && + px <= rect.right && + py >= rect.top && + py <= rect.bottom, + ); + const pseudoHits = new Set(pseudos[index]); + const textAt = stack.indexOf(element); + if (textAt !== -1) + for (const node of stack.slice(textAt + 1)) + if ( + !node.contains(element) && + ownGlyphs(node) && + !transparent(node) + ) + drawers.add(node); + const entries: (PaintEntry & { unsure?: true })[] = stack.map( + (node, at) => { + if (node === element) { + if (!holds(node)) return { role: "text", paint: "none" }; + // Its own paint may or may not lie under the point. + if (turned && paintsOwnBox) + return { role: "text", paint: "none", unsure: true }; + const paint = paintOf(node); + return { + role: "text", + paint: paint === "solid" ? "solid" : "none", + box: 0, + }; + } + if (node.contains(element)) { + // Above its own text, an ancestor is hit through a pseudo-element. + if (at < textAt) + return pseudoHits.has(node) && !pseudoPaints(node) + ? { role: "other", paint: "none" } + : { role: "ancestor", paint: "some" }; + if (!holds(node)) return { role: "ancestor", paint: "none" }; + const paint = paintOf(node); + return { + role: "ancestor", + paint: paint === "solid" ? "solid" : "none", + box: depth.get(node) ?? -1, + }; + } + return { role: "other", paint: paintOf(node) }; + }, + ); + // Where the text's own paint is unsure, it is paint the layers + // cannot place: as if right under the text. + const unsure = entries.findIndex((entry) => entry.unsure === true); + if (unsure !== -1) + entries.splice( + unsure, + 1, + { role: "text", paint: "none" }, + { + role: "other", + paint: "some", + }, + ); + // A painting pseudo-element at this point, in an unknown place in + // the stack: as if right under the text. + if ( + textAt !== -1 && + [...pseudoHits].some((node) => pseudoPaints(node)) + ) + entries.splice(textAt + 1, 0, { role: "other", paint: "some" }); + return entries; + }); + // Only now, with every geometry read taken where the text was + // scrolled to. Instant, so `scroll-behavior: smooth` cannot leave a + // scroller mid-animation for the next target to save. + for (const [node, left, top] of scrolled) + if (node.scrollLeft !== left || node.scrollTop !== top) + node.scrollTo({ left, top, behavior: "instant" }); + window.scrollTo({ left: x, top: y, behavior: "instant" }); + return { points, drawers }; + }; + + return targets.map((selector) => { + const element = document.querySelector(selector); + if (element === null) return null; + // How many ancestors out from the text each element is: its `box`. + const depth = new Map(); + for ( + let node: Element | null = element, at = 0; + node !== null; + node = node.parentElement, at++ + ) + depth.set(node, at); + const own = getComputedStyle(element); + // A clipped background is the glyphs' fill, not a layer under them. + let clip: ClippedBackground | undefined; + if (clipsText(own)) { + const image = own.backgroundImage; + const gradients = image.match(/gradient\(/g)?.length ?? 0; + if ( + image !== "none" && + (gradients !== 1 || + /url\(|color-mix\(|light-dark\(|currentcolor|\sin\s/i.test(image)) + ) + return null; + const stops = + image === "none" + ? [] + : (image.match( + /(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/gi, + ) ?? []); + clip = { + color: resolve(own.backgroundColor), + stops: stops.map(resolve), + }; + } + const layers: ContrastLayer[] = []; + for ( + let node: Element | null = element, box = 0; + node !== null; + node = node.parentElement, box++ + ) { + const style = getComputedStyle(node); + if (style.filter !== "none" || style.mixBlendMode !== "normal") + return null; + // A node's image paints over its colour; its backdrop filter works + // on what is behind it; its opacity fades all of it. + if (!(node === element && clip !== undefined)) { + if (style.backgroundImage !== "none") + layers.push({ kind: "unresolved", box }); + const color = resolve(style.backgroundColor); + if (color[3] > 0) layers.push({ kind: "background", color, box }); + } + if (style.backdropFilter !== "none") + layers.push({ kind: "unresolved", box }); + const opacity = Number(style.opacity); + if (opacity < 1) layers.push({ kind: "opacity", value: opacity }); + } + // An empty field shows its placeholder in the placeholder's colour. + const placeholder = + (element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement) && + element.value === "" && + element.placeholder !== ""; + const lettering = placeholder + ? getComputedStyle(element, "::placeholder") + : own; + const text = resolve(lettering.color); + // Computes to `color` unless set. + const fill = resolve( + lettering.getPropertyValue("-webkit-text-fill-color") || + lettering.color, + ); + const stroke = { + width: Number.parseFloat( + lettering.getPropertyValue("-webkit-text-stroke-width") || "0", + ), + color: resolve( + lettering.getPropertyValue("-webkit-text-stroke-color") || + "transparent", + ), + }; + const { points, drawers } = pointsOf(element, depth); + return { + text, + fill, + stroke, + ...(clip === undefined ? {} : { clip }), + mirrored: + drawers.size > 0 && + [...drawers].every((drawer) => judgedElements.has(drawer)), + layers, + points, + fontSizePx: Number.parseFloat(own.fontSize), + fontWeight: Number(own.fontWeight), + }; + }); + }, + [selectors, judged] as const, + ); +} diff --git a/e2e/contrast-fixtures.spec.ts b/e2e/contrast-fixtures.spec.ts new file mode 100644 index 00000000..94b57eda --- /dev/null +++ b/e2e/contrast-fixtures.spec.ts @@ -0,0 +1,157 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +import { contrastSamples } from "./contrast-dom.js"; +import { judgeContrast, type ContrastVerdict } from "./contrast.js"; + +// The stories sweep's contrast fallback on hand-built pages: each fixture is +// text axe leaves "incomplete", and the fallback must not vouch for any of it +// that renders below its floor. + +const DARK = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ); + +const page = (body: string) => + `${body}`; + +// Lines 2-3 overflow a white card onto black: #555 on #000 is ~2.8:1. +const SPILL = `

On the card
Spills onto black
Also on black

`; + +// The same markup where the sampler has to scroll to it first. +const placed = (markup: string) => + [ + ["in view", page(markup)], + [ + "below the fold", + page( + `
${markup}
`, + ), + ], + [ + "inside nested scrollers", + page( + `
${markup}
`, + ), + ], + ] as const; + +// A chip with its own white ground, rotated, whose later lines spill onto +// black: client rects bound the turned chip from outside. +const chip = (degrees: number, align: string) => + page( + `

On the chip
Spills onto black
Also on black

`, + ); + +/** The fallback's verdict on #t, and whether axe left #t incomplete. */ +async function verdictFor( + tab: Page, + html: string, +): Promise<{ incomplete: boolean; verdict: ContrastVerdict }> { + await tab.setContent(html); + const { incomplete } = await new AxeBuilder({ page: tab }) + .withRules(["color-contrast"]) + .analyze(); + const [sample = null] = await contrastSamples(tab, ["#t"], []); + return { + incomplete: incomplete + .flatMap((result) => result.nodes) + .some((node) => node.target[0] === "#t"), + verdict: judgeContrast(sample), + }; +} + +const NOT_VOUCHED = [ + ...placed(SPILL).map( + ([where, html]) => + [ + `text overflowing its card onto another surface, ${where}`, + html, + ] as const, + ), + ["a rotated chip's text spilling off it (10deg, left)", chip(10, "left")], + ["a rotated chip's text spilling off it (-10deg, right)", chip(-10, "right")], + // The glyphs are painted by the #ddd-#eee gradient, not by color. + [ + "gradient text through -webkit-text-fill-color", + page( + `

Generating 2 images

`, + ), + ], + [ + "a pointer-events:none wash over an image", + page( + `
Caption
`, + ), + ], + [ + "an ancestor ::before painting an image", + page( + `
Caption
`, + ), + ], +] as const; + +for (const [name, html] of NOT_VOUCHED) + test(`the contrast fallback does not pass ${name}`, async ({ page: tab }) => { + const { incomplete, verdict } = await verdictFor(tab, html); + expect(incomplete).toBe(true); + expect(verdict).not.toMatchObject({ kind: "measured", pass: true }); + }); + +// Controls: the same shapes with legible text still measure, so the +// fixtures above fail for the reason they name. +const VOUCHED = [ + [ + "text that stays on its card", + page( + `

On the card
Still on the card
Also on the card

`, + ), + ], + [ + "dark gradient text through -webkit-text-fill-color", + page( + `

Generating 2 images

`, + ), + ], + [ + "text under an empty stretched button", + page( + `
quarter-plan.pdf
`, + ), + ], +] as const; + +for (const [name, html] of VOUCHED) + test(`the contrast fallback measures ${name}`, async ({ page: tab }) => { + const { verdict } = await verdictFor(tab, html); + expect(verdict).toMatchObject({ kind: "measured", pass: true }); + }); + +// The verdict does not depend on where the text starts: every geometry read +// happens where the sampler scrolled it to. +const SAME_ANYWHERE = [ + ["text overflowing its card", SPILL], + [ + "text on its own faint wash", + `
Caption on a wash
`, + ], + [ + "text that stays on its card", + `

On the card
Still on the card
Also on the card

`, + ], +] as const; + +for (const [name, markup] of SAME_ANYWHERE) + test(`the contrast fallback judges ${name} the same wherever it starts`, async ({ + page: tab, + }) => { + const verdicts: ContrastVerdict[] = []; + for (const [, html] of placed(markup)) + verdicts.push((await verdictFor(tab, html)).verdict); + const [inView] = verdicts; + expect(inView?.kind).toBe("measured"); + for (const verdict of verdicts) expect(verdict).toEqual(inView); + }); diff --git a/e2e/contrast.test.ts b/e2e/contrast.test.ts new file mode 100644 index 00000000..5bc87886 --- /dev/null +++ b/e2e/contrast.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, test } from "bun:test"; + +import { + contrastFloor, + contrastRatio, + flatten, + gradientColors, + inks, + judgeContrast, + layersAt, + over, + readPoint, + type ContrastLayer, + type PaintEntry, + type RGBA, +} from "./contrast.js"; + +const WHITE: RGBA = [255, 255, 255, 1]; +const BLACK: RGBA = [0, 0, 0, 1]; + +function background(color: RGBA): ContrastLayer { + return { kind: "background", color }; +} + +const TEXT: PaintEntry = { role: "text", paint: "none", box: 0 }; +const ANCESTOR: PaintEntry = { role: "ancestor", paint: "none", box: 1 }; +const GROUND: PaintEntry = { role: "ancestor", paint: "solid", box: 2 }; +const EMPTY: PaintEntry = { role: "other", paint: "none" }; +const COVER: PaintEntry = { role: "other", paint: "solid" }; +const PAINT: PaintEntry = { role: "other", paint: "some" }; + +describe("contrast", () => { + test("black on white is 21:1, and order does not matter", () => { + expect(contrastRatio(BLACK, WHITE)).toBeCloseTo(21, 5); + expect(contrastRatio(WHITE, BLACK)).toBeCloseTo(21, 5); + }); + + test("a translucent layer blends into what is under it", () => { + expect(over([0, 0, 0, 0.5], WHITE)).toEqual([127.5, 127.5, 127.5, 1]); + expect(over([0, 0, 0, 0], WHITE)).toEqual(WHITE); + expect(over([0, 0, 0, 0], [0, 0, 0, 0])).toEqual([0, 0, 0, 0]); + }); + + test("layers flatten from the opaque bottom up", () => { + // 12% orange over white, as the attention wash sits on the light ground. + const wash: RGBA = [233, 132, 40, 0.12]; + const painted = flatten(BLACK, [background(wash), background(WHITE)]); + const [r, g, b, a] = painted?.background ?? [0, 0, 0, 0]; + expect(a).toBeCloseTo(1, 9); + expect(r).toBeCloseTo(252.36, 2); + expect(g).toBeCloseTo(240.24, 2); + expect(b).toBeCloseTo(229.2, 2); + expect(painted?.text).toEqual(BLACK); + expect(flatten(BLACK, [background(wash)])).toBeNull(); + expect(flatten(BLACK, [])).toBeNull(); + }); + + test("an ancestor's opacity above the opaque ground fades text and ground", () => { + // Black on a white card at 50% over a black page: grey on black. + const painted = flatten(BLACK, [ + background(WHITE), + { kind: "opacity", value: 0.5 }, + background(BLACK), + ]); + expect(painted?.text).toEqual([0, 0, 0, 1]); + expect(painted?.background).toEqual([127.5, 127.5, 127.5, 1]); + const verdict = judgeContrast({ + text: BLACK, + layers: [ + background(WHITE), + { kind: "opacity", value: 0.3 }, + background(BLACK), + ], + points: [], + fontSizePx: 14, + fontWeight: 400, + }); + expect(verdict).toMatchObject({ kind: "measured", pass: false }); + // The same ancestor at full opacity passes. + expect( + judgeContrast({ + text: BLACK, + layers: [background(WHITE), background(BLACK)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }), + ).toMatchObject({ kind: "measured", pass: true }); + }); + + test("unresolved paint only matters where it shows through", () => { + const hidden = flatten(BLACK, [ + background(WHITE), + { kind: "unresolved" }, + background(BLACK), + ]); + expect(hidden?.background).toEqual(WHITE); + expect( + flatten(BLACK, [ + background([255, 255, 255, 0.5]), + { kind: "unresolved" }, + background(WHITE), + ]), + ).toBeNull(); + // Faded, the opaque ground lets an image behind it show through. + expect( + flatten(BLACK, [ + background(WHITE), + { kind: "opacity", value: 0.5 }, + { kind: "unresolved" }, + background(WHITE), + ]), + ).toBeNull(); + }); + + test("large text gets the 3:1 floor", () => { + expect(contrastFloor(16, 400)).toBe(4.5); + expect(contrastFloor(24, 400)).toBe(3); + expect(contrastFloor(19, 700)).toBe(3); + expect(contrastFloor(19, 600)).toBe(4.5); + }); + + test("judges text on a translucent background", () => { + const wash: RGBA = [233, 132, 40, 0.12]; + const ink: RGBA = [43, 38, 39, 1]; + const verdict = judgeContrast({ + text: ink, + layers: [background(wash), background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }); + expect(verdict.kind).toBe("measured"); + if (verdict.kind === "measured") { + expect(verdict.pass).toBe(true); + expect(verdict.ratio).toBeGreaterThan(12); + } + }); + + test("translucent text is composited before it is measured", () => { + const verdict = judgeContrast({ + text: [0, 0, 0, 0.2], + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }); + expect(verdict).toMatchObject({ kind: "measured", pass: false }); + }); + + test("no opaque ground, no sample or invisible text is unmeasured", () => { + expect( + judgeContrast({ + text: BLACK, + layers: [background([255, 255, 255, 0.5])], + points: [], + fontSizePx: 14, + fontWeight: 400, + }), + ).toEqual({ kind: "unmeasured" }); + expect(judgeContrast(null)).toEqual({ kind: "unmeasured" }); + expect( + judgeContrast({ + text: [0, 0, 0, 0], + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }), + ).toEqual({ kind: "unmeasured" }); + }); + + test("a point reads clear when only empty boxes overlap the text", () => { + // A transparent stretched button over a tile's label. + expect(readPoint([EMPTY, TEXT, ANCESTOR, GROUND])).toBe("clear"); + expect(readPoint([TEXT, ANCESTOR])).toBe("clear"); + }); + + test("a solid surface over the text hides that point", () => { + // A later tile in a stack covers an earlier tile's label. + expect(readPoint([EMPTY, COVER, PAINT, TEXT, GROUND])).toBe("hidden"); + // The covering tile's own label paints on the tile, not on the text. + expect(readPoint([PAINT, EMPTY, COVER, TEXT, GROUND])).toBe("hidden"); + // Clipped by an overflow ancestor: the text is not hit at all. + expect(readPoint([GROUND])).toBe("hidden"); + expect(readPoint([])).toBe("hidden"); + }); + + test("paint over the text, or under it above its ground, is unresolved", () => { + expect(readPoint([PAINT, TEXT, GROUND])).toBe("unresolved"); + // An ancestor above its own text paints through a pseudo-element. + expect(readPoint([ANCESTOR, TEXT, GROUND])).toBe("unresolved"); + // An image beside the text's own wrappers but above the card it sits on. + expect(readPoint([TEXT, ANCESTOR, PAINT, GROUND])).toBe("unresolved"); + expect(readPoint([TEXT, ANCESTOR, COVER, GROUND])).toBe("unresolved"); + }); + + test("anything under a solid ground in the text's layers is hidden by it", () => { + expect(readPoint([TEXT, GROUND, PAINT])).toBe("clear"); + // The text's own opaque chip is its ground. + expect(readPoint([{ role: "text", paint: "solid", box: 0 }, PAINT])).toBe( + "clear", + ); + // A solid ancestor hit without its box under the point grounds nothing. + expect(readPoint([TEXT, { role: "ancestor", paint: "solid" }, PAINT])).toBe( + "unresolved", + ); + }); + + test("an unresolved point leaves the text unmeasured", () => { + const sample = { + text: BLACK, + layers: [background(WHITE)], + fontSizePx: 14, + fontWeight: 400, + }; + expect( + judgeContrast({ + ...sample, + points: [ + [TEXT, GROUND], + [PAINT, TEXT], + ], + }), + ).toEqual({ kind: "unmeasured" }); + // Hidden everywhere, it is held to the layers it shows on once uncovered. + expect( + judgeContrast({ ...sample, points: [[COVER, TEXT], [GROUND]] }), + ).toMatchObject({ kind: "measured", pass: true }); + }); + + test("a gradient passes through every colour between its stops", () => { + const colors = gradientColors([BLACK, WHITE], 4); + expect(colors).toEqual([ + BLACK, + [63.75, 63.75, 63.75, 1], + [127.5, 127.5, 127.5, 1], + [191.25, 191.25, 191.25, 1], + WHITE, + ]); + // Premultiplied: fading to transparent keeps the colour, not grey. + expect(gradientColors([WHITE, [0, 0, 0, 0]], 2)[1]).toEqual([ + 255, 255, 255, 0.5, + ]); + expect(gradientColors([])).toEqual([]); + }); + + test("text clipping a gradient is held to its worst colour", () => { + const grey: RGBA = [150, 150, 150, 1]; + const shimmer = (stops: readonly RGBA[]) => + judgeContrast({ + text: [0, 0, 0, 0], + clip: { color: [0, 0, 0, 0], stops }, + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }); + expect(shimmer([BLACK, BLACK])).toMatchObject({ + kind: "measured", + pass: true, + }); + const verdict = shimmer([BLACK, grey, BLACK]); + expect(verdict).toMatchObject({ kind: "measured", pass: false }); + if (verdict.kind === "measured") + expect(verdict.ratio).toBeCloseTo(contrastRatio(grey, WHITE), 5); + // Opaque text colour paints over the clipped gradient. + expect( + judgeContrast({ + text: BLACK, + clip: { color: [0, 0, 0, 0], stops: [grey] }, + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }), + ).toMatchObject({ kind: "measured", pass: true }); + expect(shimmer([[0, 0, 0, 0]])).toEqual({ kind: "unmeasured" }); + }); + + test("transparent glyphs defer to a mirror that is judged itself", () => { + const field = { + text: [0, 0, 0, 0] as const, + layers: [background(WHITE)], + // The mirror's words sit under the field at every point. + points: [[TEXT, PAINT, GROUND]], + fontSizePx: 14, + fontWeight: 400, + }; + expect(judgeContrast({ ...field, mirrored: true })).toEqual({ + kind: "mirrored", + }); + expect(judgeContrast({ ...field, mirrored: false })).toEqual({ + kind: "unmeasured", + }); + // Visible ink is measured, mirror or not. + expect( + judgeContrast({ ...field, text: BLACK, points: [], mirrored: true }), + ).toMatchObject({ kind: "measured" }); + }); + + test("each point is measured on the boxes under it, worst point wins", () => { + // #555 text on a white card (box 1) inside a black section (box 2); its + // later lines spill past the card onto the section. + const grey: RGBA = [85, 85, 85, 1]; + const section: ContrastLayer = { kind: "background", color: BLACK, box: 2 }; + const layers: ContrastLayer[] = [ + { kind: "background", color: WHITE, box: 1 }, + section, + { kind: "background", color: WHITE, box: 3 }, + ]; + const onCard: PaintEntry[] = [ + TEXT, + { role: "ancestor", paint: "solid", box: 1 }, + { role: "ancestor", paint: "solid", box: 2 }, + ]; + const offCard: PaintEntry[] = [ + TEXT, + { role: "ancestor", paint: "solid", box: 2 }, + ]; + expect(layersAt(layers, offCard)).toEqual([section]); + const sample = { text: grey, layers, fontSizePx: 14, fontWeight: 400 }; + expect(judgeContrast({ ...sample, points: [onCard] })).toMatchObject({ + kind: "measured", + pass: true, + }); + const spilled = judgeContrast({ ...sample, points: [onCard, offCard] }); + expect(spilled).toMatchObject({ kind: "measured", pass: false }); + if (spilled.kind === "measured") + expect(spilled.ratio).toBeCloseTo(contrastRatio(grey, BLACK), 5); + // Opacity fades the group wherever it paints, box or not. + expect( + layersAt([{ kind: "opacity", value: 0.5 }, ...layers], offCard)[0], + ).toEqual({ kind: "opacity", value: 0.5 }); + }); + + test("the glyphs paint in -webkit-text-fill-color, not color", () => { + const base = { + text: BLACK, + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }; + // color:#000 with a transparent fill over a #ddd-#eee clipped gradient. + const pale = judgeContrast({ + ...base, + fill: [0, 0, 0, 0], + clip: { + color: [0, 0, 0, 0], + stops: [ + [221, 221, 221, 1], + [238, 238, 238, 1], + ], + }, + }); + expect(pale).toMatchObject({ kind: "measured", pass: false }); + if (pale.kind === "measured") expect(pale.ratio).toBeLessThan(1.5); + // A set fill wins over color; unset, the fill computes to color. + expect(inks({ ...base, fill: [200, 200, 200, 1] })).toEqual([ + [200, 200, 200, 1], + ]); + expect(inks(base)).toEqual([BLACK]); + expect(judgeContrast({ ...base, fill: [0, 0, 0, 0], text: BLACK })).toEqual( + { kind: "unmeasured" }, + ); + }); + + test("a visible text stroke leaves the text unmeasured", () => { + const base = { + text: BLACK, + layers: [background(WHITE)], + points: [], + fontSizePx: 14, + fontWeight: 400, + }; + expect( + judgeContrast({ ...base, stroke: { width: 1, color: WHITE } }), + ).toEqual({ kind: "unmeasured" }); + expect( + judgeContrast({ ...base, stroke: { width: 0, color: WHITE } }), + ).toMatchObject({ kind: "measured", pass: true }); + expect( + judgeContrast({ ...base, stroke: { width: 2, color: [0, 0, 0, 0] } }), + ).toMatchObject({ kind: "measured", pass: true }); + }); +}); diff --git a/e2e/contrast.ts b/e2e/contrast.ts new file mode 100644 index 00000000..d799eef3 --- /dev/null +++ b/e2e/contrast.ts @@ -0,0 +1,286 @@ +// WCAG contrast for text that axe leaves "incomplete": the stories sweep reads +// an element's resolved colours in the browser and judges them here, so the +// maths runs (and is tested) outside the page. + +/** sRGB channels 0-255 and alpha 0-1. */ +export type RGBA = readonly [number, number, number, number]; + +/** + * One thing an ancestor paints under or applies to the text: its background + * colour; its `opacity`, which fades everything inside it (the text and every + * background between) as one group; or paint no colour stands for (an image, + * a gradient, a backdrop filter), which only matters where it shows through. + * A background or unresolved paint names its `box`: how many ancestors out + * from the text element (0) the element painting it is. It lies under a point + * only where that box does, so text spilling past a card is not measured + * against the card. Without a `box` it lies under every point. Opacity fades + * the whole group, overflow included, so it has none. + */ +export type ContrastLayer = + | { readonly kind: "background"; readonly color: RGBA; readonly box?: number } + | { readonly kind: "opacity"; readonly value: number } + | { readonly kind: "unresolved"; readonly box?: number }; + +/** + * One element the browser hit-tests at a point on the text, top first: the + * text element itself, one of its ancestors (whose paint is in the layers), + * or any other element. Its paint there is nothing, a solid surface (an + * opaque background nothing fades), or something else (an image, a + * translucent wash, glyphs, a border, a painting pseudo-element). + */ +export type PaintEntry = { + readonly role: "text" | "ancestor" | "other"; + readonly paint: "none" | "solid" | "some"; + /** + * For the text element or an ancestor whose own box is under the point: + * its `box` in the layers. Absent, the element is hit without its box + * holding the point (text overflowing its own box), and it paints nothing + * there. Only an entry with a box can be "solid". + */ + readonly box?: number; +}; + +/** What one hit-tested point says about the text there. */ +export type PointReading = "clear" | "hidden" | "unresolved"; + +/** + * Whether the text at one point shows over its own layers alone ("clear"), + * is not painted there at all ("hidden": covered by a solid surface, or + * clipped so the text is not hit), or shares the point with paint the layers + * do not hold ("unresolved"). + */ +export function readPoint(stack: readonly PaintEntry[]): PointReading { + const text = stack.findIndex((entry) => entry.role === "text"); + if (text === -1) return "hidden"; + const above = stack.slice(0, text); + // A solid surface over the text hides it, whatever else paints above. + if (above.some((entry) => entry.role === "other" && entry.paint === "solid")) + return "hidden"; + if (above.some((entry) => entry.role !== "other" || entry.paint !== "none")) + return "unresolved"; + for (const entry of stack.slice(text)) { + // A solid ground in the text's own layers hides everything under it. + if ( + entry.role !== "other" && + entry.paint === "solid" && + entry.box !== undefined + ) + return "clear"; + if (entry.role === "other" && entry.paint !== "none") return "unresolved"; + } + return "clear"; +} + +/** + * The element's own background where `background-clip: text` paints it + * inside the glyphs: its colour and, under the text colour, its gradient's + * colour stops in order. + */ +export type ClippedBackground = { + readonly color: RGBA; + readonly stops: readonly RGBA[]; +}; + +/** What the browser reports for one text element. */ +export type ContrastSample = { + /** The computed `color`. */ + readonly text: RGBA; + /** + * The computed `-webkit-text-fill-color`, which paints the glyphs: it + * computes to `color` unless set, and set transparent it lets a clipped + * background show as the glyphs' colour. + */ + readonly fill?: RGBA; + /** `-webkit-text-stroke`, which paints an outline over the glyphs. */ + readonly stroke?: { readonly width: number; readonly color: RGBA }; + readonly clip?: ClippedBackground; + /** + * For a field whose own glyphs are transparent: every element drawing + * glyphs under it (a mirror painting the words) is one axe judges itself. + */ + readonly mirrored?: boolean; + /** The element's and every ancestor's layers, from the element out to the root. */ + readonly layers: readonly ContrastLayer[]; + /** The hit-test stack at each point sampled across the text. */ + readonly points: readonly (readonly PaintEntry[])[]; + readonly fontSizePx: number; + readonly fontWeight: number; +}; + +export type ContrastVerdict = + | { + readonly kind: "measured"; + readonly ratio: number; + readonly floor: number; + readonly pass: boolean; + } + | { readonly kind: "mirrored" } + | { readonly kind: "unmeasured" }; + +/** `top` painted over `bottom` (source-over). */ +export function over(top: RGBA, bottom: RGBA): RGBA { + const alpha = top[3] + bottom[3] * (1 - top[3]); + if (alpha === 0) return [0, 0, 0, 0]; + const channel = (index: 0 | 1 | 2) => + (top[index] * top[3] + bottom[index] * bottom[3] * (1 - top[3])) / alpha; + return [channel(0), channel(1), channel(2), alpha]; +} + +/** `from` blended toward `to` by `t`, premultiplied, as a CSS gradient does in sRGB. */ +function mix(from: RGBA, to: RGBA, t: number): RGBA { + const alpha = from[3] * (1 - t) + to[3] * t; + if (alpha === 0) return [0, 0, 0, 0]; + const channel = (index: 0 | 1 | 2) => + (from[index] * from[3] * (1 - t) + to[index] * to[3] * t) / alpha; + return [channel(0), channel(1), channel(2), alpha]; +} + +/** + * Every colour a gradient through `stops` passes, sampled `steps` times + * between each pair: the darkest or lightest can fall between two stops. + */ +export function gradientColors( + stops: readonly RGBA[], + steps = 8, +): readonly RGBA[] { + const [first, ...rest] = stops; + if (first === undefined) return []; + const colors: RGBA[] = [first]; + let from = first; + for (const to of rest) { + for (let step = 1; step <= steps; step++) + colors.push(mix(from, to, step / steps)); + from = to; + } + return colors; +} + +/** `color` with its alpha scaled, as an ancestor's `opacity` does. */ +function fade(color: RGBA, opacity: number): RGBA { + return [color[0], color[1], color[2], color[3] * opacity]; +} + +// Alpha that compositing left a rounding error short of 1 is opaque. +function opaque(color: RGBA): boolean { + return color[3] > 1 - 1e-9; +} + +/** + * The text pixel and the background pixel beside it, painted through every + * layer from the element out to the root. `null` when unresolved paint shows + * through, or when the background is still not opaque at the root. + */ +export function flatten( + text: RGBA, + layers: readonly ContrastLayer[], +): { readonly text: RGBA; readonly background: RGBA } | null { + let ink = text; + let ground: RGBA = [0, 0, 0, 0]; + for (const layer of layers) { + switch (layer.kind) { + case "background": + ink = over(ink, layer.color); + ground = over(ground, layer.color); + break; + case "opacity": + ink = fade(ink, layer.value); + ground = fade(ground, layer.value); + break; + case "unresolved": + if (!opaque(ground)) return null; + break; + } + } + // The text pixel is at least as opaque as the ground under it. + return opaque(ground) ? { text: ink, background: ground } : null; +} + +function luminance([r, g, b]: RGBA): number { + const linear = (value: number) => { + const channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +/** WCAG contrast ratio of two opaque colours. */ +export function contrastRatio(a: RGBA, b: RGBA): number { + const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return ((light ?? 0) + 0.05) / ((dark ?? 0) + 0.05); +} + +/** 3:1 for large text (24px, or 18.66px bold), 4.5:1 for the rest, as axe does. */ +export function contrastFloor(fontSizePx: number, fontWeight: number): number { + const large = fontSizePx >= 24 || (fontSizePx >= 18.66 && fontWeight >= 700); + return large ? 3 : 4.5; +} + +/** + * Every colour the glyphs can paint: the text colour, over the background + * the glyphs clip when there is one. + */ +export function inks(sample: ContrastSample): readonly RGBA[] { + const glyph = sample.fill ?? sample.text; + if (sample.clip === undefined) return [glyph]; + const { color, stops } = sample.clip; + const fills = + stops.length > 0 ? gradientColors(stops) : [[0, 0, 0, 0] as const]; + return fills.map((fill) => over(glyph, over(fill, color))); +} + +/** The layers under one clear point: those whose box holds the point. */ +export function layersAt( + layers: readonly ContrastLayer[], + stack: readonly PaintEntry[], +): readonly ContrastLayer[] { + const boxes = new Set(stack.flatMap((entry) => entry.box ?? [])); + return layers.filter( + (layer) => + layer.kind === "opacity" || + layer.box === undefined || + boxes.has(layer.box), + ); +} + +/** + * Text is unmeasured when paint the layers do not hold shares a point with + * it, or when a stroke outlines its glyphs. Fully transparent ink paints + * nothing: its words are "mirrored" when the elements drawing them (an + * overlay mirroring a textarea, say) are judged in their own right, and + * unmeasured otherwise. Each clear point is measured on the layers under it, + * and the text is held to its worst point and, for clipped gradient text, + * its worst colour. Text hidden at every point is measured on all its + * layers, as it shows once scrolled or uncovered. + */ +export function judgeContrast(sample: ContrastSample | null): ContrastVerdict { + if (sample === null) return { kind: "unmeasured" }; + if ( + sample.stroke !== undefined && + sample.stroke.width > 0 && + sample.stroke.color[3] > 0 + ) + return { kind: "unmeasured" }; + const colors = inks(sample); + if (colors.every((ink) => ink[3] === 0)) + return sample.mirrored === true + ? { kind: "mirrored" } + : { kind: "unmeasured" }; + const readings = sample.points.map(readPoint); + if (readings.includes("unresolved")) return { kind: "unmeasured" }; + const clear = sample.points.filter((_, index) => readings[index] === "clear"); + const grounds = + clear.length > 0 + ? clear.map((stack) => layersAt(sample.layers, stack)) + : [sample.layers]; + let ratio = Infinity; + for (const layers of grounds) + for (const ink of colors) { + const painted = flatten(ink, layers); + if (painted === null) return { kind: "unmeasured" }; + ratio = Math.min(ratio, contrastRatio(painted.text, painted.background)); + } + const floor = contrastFloor(sample.fontSizePx, sample.fontWeight); + return { kind: "measured", ratio, floor, pass: ratio >= floor }; +} diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 34405888..ae0686b9 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -48,6 +48,11 @@ import { PanelTabs, panelTabsHeaderClassName, useTabs, + Activity, + ToolApproval, + groupActivity, + type PartTool, + type ToolApprovalRecord, } from "@corbits/react-ui"; import { CommandPalette, @@ -940,6 +945,64 @@ function PanelTabsScenario() { ); } +// 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`. +function ToolApprovalScenario() { + const { log, record } = useEventLog(); + const [approval, setApproval] = useState({ + id: "approval-1", + status: "pending", + scope: null, + timeoutAt: null, + }); + const parts: PartTool[] = [ + { + kind: "tool-trace", + toolCallId: "draft", + name: "mail__draft", + status: "output-available", + output: "Draft ready", + }, + { + kind: "tool-trace", + toolCallId: "send", + name: "mail__send", + status: + approval.status === "pending" + ? "approval-requested" + : approval.status === "approved" + ? "output-available" + : "output-denied", + 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, + )} + {log} + + ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -982,4 +1045,5 @@ export const scenarios: Record ReactNode> = { "prompt-input-toolbar": () => , "prompt-input-toolbar-dialog": () => , "panel-tabs": () => , + "tool-approval": () => , }; diff --git a/e2e/stories-a11y.spec.ts b/e2e/stories-a11y.spec.ts index 9439c5b1..533cd678 100644 --- a/e2e/stories-a11y.spec.ts +++ b/e2e/stories-a11y.spec.ts @@ -1,6 +1,8 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; +import { contrastSamples } from "./contrast-dom.js"; +import { judgeContrast } from "./contrast.js"; import known from "./stories-a11y.known.json" with { type: "json" }; const STORIES = "http://localhost:61000"; @@ -59,7 +61,9 @@ test("every story is free of serious and critical axe violations", async ({ new Promise((resolve) => setTimeout(resolve, 2_000)), ]); }); - const { violations } = await new AxeBuilder({ page }).analyze(); + const { passes, violations, incomplete } = await new AxeBuilder({ + page, + }).analyze(); for (const violation of violations) { if (violation.impact === "serious" || violation.impact === "critical") { const key = `${story} (${theme}): ${violation.id}`; @@ -68,6 +72,43 @@ test("every story is free of serious and critical axe violations", async ({ } } } + // Axe leaves contrast "incomplete" when it cannot settle the colours, + // e.g. text on a translucent wash. Measure those here, so every such + // node gets a verdict: measured below its floor is a failure, and a + // node nothing can measure is reported until it is listed in KNOWN. + const unsettled = incomplete + .filter((result) => result.id === "color-contrast") + .flatMap((result) => result.nodes); + const judged = [...passes, ...violations, ...incomplete] + .filter((result) => result.id === "color-contrast") + .flatMap((result) => result.nodes) + .flatMap(({ target: [selector, ...rest] }) => + typeof selector === "string" && rest.length === 0 ? [selector] : [], + ); + // Nodes inside a frame or shadow root have no single selector. + const measurable = unsettled.flatMap((node) => { + const [selector] = node.target; + return typeof selector === "string" && node.target.length === 1 + ? [selector] + : []; + }); + const verdicts = (await contrastSamples(page, measurable, judged)).map( + judgeContrast, + ); + const failing = verdicts.filter( + (verdict) => verdict.kind === "measured" && !verdict.pass, + ).length; + const unmeasured = + unsettled.length - + measurable.length + + verdicts.filter((verdict) => verdict.kind === "unmeasured").length; + for (const [id, count] of [ + ["color-contrast-measured", failing], + ["color-contrast-unmeasured", unmeasured], + ] as const) { + const key = `${story} (${theme}): ${id}`; + if (count > 0 && !KNOWN.has(key)) failures.push(`${key} (${count})`); + } } } diff --git a/e2e/tool-approval.spec.ts b/e2e/tool-approval.spec.ts new file mode 100644 index 00000000..ca026268 --- /dev/null +++ b/e2e/tool-approval.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +const DECISIONS = [ + ["Approve", "approved:once", "Approved once"], + ["Approve always", "approved:always", "Approved always"], + ["Deny", "rejected:once", "Denied"], +] as const; + +for (const [button, event, outcome] of DECISIONS) { + test(`${button} records its decision once and folds the call`, async ({ + page, + }) => { + await page.goto("/tool-approval"); + const approval = page.getByRole("region", { name: "Needs your approval" }); + await expect(approval).toBeVisible(); + await expect(approval).toContainText("mail__send"); + + await page.getByRole("button", { name: button, exact: true }).click(); + + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + event, + ); + await expect(approval).toHaveCount(0); + await expect(page.getByRole("button", { name: "Approve" })).toHaveCount(0); + // The settled call joins the run before it; opened, it shows the outcome. + const run = page.getByRole("button", { name: "Worked through 2 steps" }); + await run.click(); + await expect(page.getByText(outcome, { exact: true })).toBeVisible(); + }); +} + +test("the decision works from the keyboard", async ({ page }) => { + await page.goto("/tool-approval"); + // The finished run's line comes first, then the input box, then Approve. + await page.getByRole("button", { name: "Worked through 1 step" }).focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("button", { name: "Approve", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + "approved:always", + ); +}); diff --git a/package.json b/package.json index bc9d1e57..cfa20e3a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "format": "oxfmt", "format:check": "oxfmt --check", "test": "bun test src --pass-with-no-tests", - "test:e2e": "bun test ./e2e/exports.test.ts && playwright test", + "test:e2e": "bun test ./e2e/exports.test.ts ./e2e/contrast.test.ts && playwright test", "check": "bun run typecheck && bun run lint && bun run format:check && bun run test", "build:css": "tailwindcss -i src/styles.css -o dist/styles.css && cp src/theme.css dist/theme.css", "dep-guard": "node scripts/dep-guard.mjs", diff --git a/src/index.ts b/src/index.ts index e7e2eff0..24026043 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,3 +137,8 @@ export * from "./ui/prompt-input.js"; export * from "./ui/panel.js"; export * from "./ui/prompt-input-toolbar.js"; export * from "./ui/panel-tabs.js"; +export * from "./ui/tool.js"; +export * from "./ui/tool-approval.js"; +export * from "./ui/tool-group.js"; +export * from "./ui/reasoning.js"; +export * from "./ui/activity.js"; diff --git a/src/lib/tool-display.test.ts b/src/lib/tool-display.test.ts new file mode 100644 index 00000000..f196017d --- /dev/null +++ b/src/lib/tool-display.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; + +import type { PartTool } from "./conversation.js"; +import { + countFailedTools, + displayedToolStatus, + formatToolValue, +} from "./tool-display.js"; + +function tool(overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: "call", + name: "files__read", + status: "output-available", + ...overrides, + }; +} + +describe("displayedToolStatus", () => { + test("a call with no record shows its own status", () => { + expect(displayedToolStatus(tool({ status: "running" }))).toBe("running"); + expect(displayedToolStatus(tool({ status: "approval-requested" }))).toBe( + "approval-requested", + ); + }); + + test("a pending record waits whatever the call's status says", () => { + const part = tool({ + status: "running", + approval: { id: "a", status: "pending", scope: null, timeoutAt: null }, + }); + expect(displayedToolStatus(part)).toBe("approval-requested"); + }); + + test("a settled record ends the wait even when the status still asks", () => { + const asking = (status: "approved" | "rejected" | "timeout" | "expired") => + tool({ + status: "approval-requested", + approval: { id: "a", status, scope: null, timeoutAt: null }, + }); + expect(displayedToolStatus(asking("approved"))).toBe("pending"); + expect(displayedToolStatus(asking("rejected"))).toBe("output-denied"); + expect(displayedToolStatus(asking("timeout"))).toBe("output-denied"); + expect(displayedToolStatus(asking("expired"))).toBe("output-denied"); + }); + + test("a settled record leaves an execution status alone", () => { + const part = tool({ + status: "error", + approval: { id: "a", status: "approved", scope: "once", timeoutAt: null }, + }); + expect(displayedToolStatus(part)).toBe("error"); + }); +}); + +describe("countFailedTools", () => { + test("counts failed calls, including each one inside a batch", () => { + expect( + countFailedTools([ + { kind: "reasoning", text: "Plan" }, + tool({ status: "error" }), + tool(), + { + kind: "tool-batch", + id: "batch", + tools: [tool({ status: "error" }), tool({ status: "error" }), tool()], + }, + ]), + ).toBe(3); + }); +}); + +describe("formatToolValue", () => { + test("keeps strings and indents everything else as JSON", () => { + expect(formatToolValue("plain")).toBe("plain"); + expect(formatToolValue({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + + test("falls back to String for values JSON cannot hold", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(formatToolValue(circular)).toBe("[object Object]"); + expect(formatToolValue(10n)).toBe("10"); + expect(formatToolValue(undefined)).toBe("undefined"); + }); +}); diff --git a/src/lib/tool-display.ts b/src/lib/tool-display.ts new file mode 100644 index 00000000..164da3dd --- /dev/null +++ b/src/lib/tool-display.ts @@ -0,0 +1,40 @@ +import type { ToolTraceStatus } from "./chat-parts.js"; +import { + isAwaitingApproval, + type ActivityStep, +} from "./conversation-activity.js"; +import type { PartTool } from "./conversation.js"; + +/** + * The execution status a tool call shows, with its approval record taken + * into account. A call waiting on a person reads as `approval-requested` + * whatever its own status says. A call whose record is settled no longer + * waits: approved, it is about to run (`pending`); refused, timed out or + * expired, it never ran (`output-denied`). + */ +export function displayedToolStatus(part: PartTool): ToolTraceStatus { + if (isAwaitingApproval(part)) return "approval-requested"; + if (part.status !== "approval-requested") return part.status; + return part.approval?.status === "approved" ? "pending" : "output-denied"; +} + +/** How many calls failed, counting each call inside a batch. */ +export function countFailedTools(steps: readonly ActivityStep[]): number { + return steps.reduce((total, step) => { + if (step.kind === "tool-batch") return total + countFailedTools(step.tools); + if (step.kind === "tool-trace" && displayedToolStatus(step) === "error") + return total + 1; + return total; + }, 0); +} + +/** Tool input or output as text: strings as they are, anything else as indented JSON. */ +export function formatToolValue(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + // Circular structures and BigInt values have no JSON form. + return String(value); + } +} diff --git a/src/ui/activity.test.tsx b/src/ui/activity.test.tsx new file mode 100644 index 00000000..95453e09 --- /dev/null +++ b/src/ui/activity.test.tsx @@ -0,0 +1,317 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, test } from "bun:test"; + +import type { PartReasoning } from "../lib/chat-parts.js"; +import type { ActivityStep } from "../lib/conversation-activity.js"; +import type { PartTool } from "../lib/conversation.js"; +import { Activity, ActivityContent, ActivityTrigger } from "./activity.js"; +import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning.js"; + +type Mounted = { + container: HTMLElement; + render: (node: React.ReactElement) => void; + unmount: () => void; +}; + +function mount(node: React.ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + render: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function call(index: number, overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: `call-${index}`, + name: `files__read_${index}`, + status: "output-available", + output: "ok", + ...overrides, + }; +} + +const thought: PartReasoning = { + kind: "reasoning", + text: "Read every agreement first.", + durationMs: 4_400, +}; + +function trigger(container: HTMLElement): HTMLButtonElement { + const element = container.querySelector( + "[data-slot='activity-trigger']", + ); + if (element === null) throw new Error("expected an activity trigger"); + return element; +} + +function shownSteps(container: HTMLElement): readonly string[] { + return [...container.querySelectorAll("li[data-step] code")].map( + (code) => code.textContent ?? "", + ); +} + +describe("Activity", () => { + test("closed, it is one summary line and none of its steps", () => { + const mounted = mount( + , + ); + expect(trigger(mounted.container).textContent).toContain( + "Thought for 4s · 2 steps", + ); + expect(trigger(mounted.container).textContent).toContain("1 failed"); + expect(trigger(mounted.container).getAttribute("aria-expanded")).toBe( + "false", + ); + expect(mounted.container.querySelector("ol")).toBeNull(); + mounted.unmount(); + }); + + test("opened, it shows the latest 10 and a control for the rest", () => { + const steps = Array.from({ length: 14 }, (_, index) => call(index)); + const mounted = mount(); + act(() => trigger(mounted.container).click()); + expect(shownSteps(mounted.container)).toEqual( + steps.slice(4).map((step) => step.name), + ); + const more = [...mounted.container.querySelectorAll("button")].find( + (button) => button.textContent === "Show 4 earlier steps", + ); + if (more === undefined) throw new Error("expected a show-earlier control"); + act(() => more.click()); + expect(shownSteps(mounted.container).length).toBe(14); + expect(mounted.container.textContent).not.toContain("earlier steps"); + // Focus lands on the first step the control revealed. + expect(document.activeElement?.textContent).toContain("files__read_0"); + mounted.unmount(); + }); + + test("ten or fewer steps need no control", () => { + const steps = Array.from({ length: 10 }, (_, index) => call(index)); + const mounted = mount(); + expect(shownSteps(mounted.container).length).toBe(10); + expect(mounted.container.textContent).not.toContain("earlier"); + mounted.unmount(); + }); + + test("controlled open follows the prop and reports clicks", () => { + const changes: boolean[] = []; + const steps: readonly ActivityStep[] = [call(1)]; + const mounted = mount( + changes.push(open)} + />, + ); + act(() => trigger(mounted.container).click()); + expect(changes).toEqual([true]); + expect(mounted.container.querySelector("ol")).toBeNull(); + mounted.render( {}} />); + expect(mounted.container.querySelector("ol")).not.toBeNull(); + mounted.unmount(); + }); + + test("while streaming, the line says what is happening now", () => { + const one = mount( + , + ); + expect(trigger(one.container).textContent).toContain("files__read_1…"); + one.unmount(); + + const many = mount( + , + ); + expect(trigger(many.container).textContent).toContain("Running 2 at once…"); + many.unmount(); + + const thinking = mount(); + expect(trigger(thinking.container).textContent).toContain("Thinking…"); + thinking.unmount(); + }); + + test("a settled approval inside the run shows its outcome", () => { + const mounted = mount( + , + ); + expect(mounted.container.textContent).toContain("Timed out"); + mounted.unmount(); + }); + + test("custom parts and step rendering", () => { + const mounted = mount( + + + ( + + {index}:{step.kind} + + )} + /> + , + ); + expect(trigger(mounted.container).textContent).toContain("Custom line"); + expect( + [...mounted.container.querySelectorAll("[data-custom]")].map( + (element) => element.textContent, + ), + ).toEqual(["0:tool-trace", "1:tool-trace"]); + mounted.unmount(); + }); + + test("a run with no steps renders nothing", () => { + const mounted = mount(); + expect(mounted.container.innerHTML).toBe(""); + mounted.unmount(); + }); +}); + +/** Text content outside `aria-hidden` subtrees: the name a button computes. */ +function spokenText(node: Node): string { + if (node instanceof Element && node.getAttribute("aria-hidden") === "true") + return ""; + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; + return [...node.childNodes].map(spokenText).join(""); +} + +describe("Reasoning", () => { + function reasoningTrigger(container: HTMLElement): HTMLButtonElement { + const element = container.querySelector( + "[data-slot='reasoning-trigger']", + ); + if (element === null) throw new Error("expected a reasoning trigger"); + return element; + } + + test("closed, it shows the duration and the start of the text", () => { + const mounted = mount(); + const button = reasoningTrigger(mounted.container); + expect(button.textContent).toContain("Thought for 4s"); + expect(button.textContent).toContain("Read every agreement first."); + expect( + mounted.container.querySelector("[data-slot='reasoning-content']"), + ).toBeNull(); + act(() => button.click()); + expect( + mounted.container.querySelector("[data-slot='reasoning-content']") + ?.textContent, + ).toBe("Read every agreement first."); + mounted.unmount(); + }); + + test("its name is the summary, not the previewed thought", () => { + const long: PartReasoning = { + kind: "reasoning", + text: "x".repeat(2_000), + durationMs: 4_400, + }; + const mounted = mount(); + const button = reasoningTrigger(mounted.container); + // The preview is still on screen. + expect(button.textContent).toContain(long.text); + expect(button.hasAttribute("aria-label")).toBe(false); + expect(spokenText(button)).toBe("Thought for 4s"); + mounted.unmount(); + }); + + test("without a duration it says Thought; streaming, Thinking…", () => { + const done = mount( + , + ); + expect(reasoningTrigger(done.container).textContent).toStartWith("Thought"); + done.unmount(); + + const live = mount( + , + ); + expect(reasoningTrigger(live.container).textContent).toContain("Thinking…"); + expect( + live.container + .querySelector("[data-slot='reasoning-content']") + ?.getAttribute("aria-busy"), + ).toBe("true"); + live.unmount(); + }); + + test("renders the text through the caller's render prop", () => { + const mounted = mount( + + + {text}} /> + , + ); + expect(mounted.container.querySelector("mark")?.textContent).toBe( + thought.text, + ); + mounted.unmount(); + }); + + test("controlled open is respected", () => { + const changes: boolean[] = []; + const mounted = mount( + changes.push(open)} + />, + ); + act(() => reasoningTrigger(mounted.container).click()); + expect(changes).toEqual([true]); + expect( + mounted.container.querySelector("[data-slot='reasoning-content']"), + ).toBeNull(); + mounted.unmount(); + }); + + test("finished thinking with no text renders nothing", () => { + const mounted = mount( + , + ); + expect(mounted.container.innerHTML).toBe(""); + mounted.unmount(); + }); +}); diff --git a/src/ui/activity.tsx b/src/ui/activity.tsx new file mode 100644 index 00000000..be87d614 --- /dev/null +++ b/src/ui/activity.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { + createContext, + useContext, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { + summarizeActivity, + type ActivityStep, +} from "../lib/conversation-activity.js"; +import type { PartTool } from "../lib/conversation.js"; +import { countFailedTools, displayedToolStatus } from "../lib/tool-display.js"; +import { cn } from "../lib/utils.js"; +import { Reasoning } from "./reasoning.js"; +import { Tool } from "./tool.js"; +import { ToolGroup } from "./tool-group.js"; + +/** An opened run lists its latest steps; older ones wait behind a control. */ +const SHOWN_STEPS = 10; + +type ActivityContextValue = { + readonly steps: readonly ActivityStep[]; + readonly streaming: boolean; + readonly open: boolean; + readonly toggle: () => void; + readonly contentId: string; +}; + +const ActivityContext = createContext(null); + +function useActivityContext(part: string): ActivityContextValue { + const context = useContext(ActivityContext); + if (context === null) throw new Error(`${part} must be inside an Activity.`); + return context; +} + +export type ActivityProps = ComponentProps<"div"> & { + /** One run of thinking and tool work, e.g. an `activity` group from `groupActivity`. */ + readonly steps: readonly ActivityStep[]; + /** The run is still going. */ + readonly streaming?: boolean | undefined; + /** Controlled open state. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + readonly defaultOpen?: boolean | undefined; + readonly onOpenChange?: ((open: boolean) => void) | undefined; +}; + +/** + * A stretch of an agent's work, thinking and tool calls however many, as one + * block. Folded by default, live or done: closed, it is one summary line + * ("Thought for 4s · 3 steps") and nothing of the steps is in the page. + * Opened, it lists every step in order, newest last, the latest ten first + * with the rest behind a control. + * + * Without children it renders `ActivityTrigger` and `ActivityContent`. + * Renders nothing for a run with no steps. + */ +export function Activity({ + steps, + streaming = false, + open: openProp, + defaultOpen = false, + onOpenChange, + className, + children, + ...props +}: ActivityProps) { + const contentId = useId(); + const [open, setOpen] = useControllableState({ + value: openProp, + defaultValue: defaultOpen, + onChange: onOpenChange, + name: "Activity", + }); + + if (steps.length === 0) return null; + + return ( + setOpen(!open), + contentId, + }} + > +
+ {children ?? ( + <> + + + + )} +
+
+ ); +} + +export type ActivityTriggerLabels = { + readonly failed: (count: number) => string; + /** While streaming, with more than one call running. */ + readonly runningMany: (count: number) => string; + /** While streaming, when the newest step is thinking. */ + readonly thinking: string; + /** While streaming, otherwise. */ + readonly working: string; +}; + +const TRIGGER_LABELS: ActivityTriggerLabels = { + failed: (count) => `${count} failed`, + runningMany: (count) => `Running ${count} at once`, + thinking: "Thinking", + working: "Working", +}; + +export type ActivityTriggerProps = { + /** Replaces the summary or live line. */ + readonly label?: ReactNode; + readonly labels?: Partial | undefined; + readonly className?: string | undefined; +}; + +function runningTools(steps: readonly ActivityStep[]): readonly PartTool[] { + return steps.flatMap((step) => { + const tools = + step.kind === "tool-batch" + ? step.tools + : step.kind === "tool-trace" + ? [step] + : []; + return tools.filter((tool) => displayedToolStatus(tool) === "running"); + }); +} + +/** While live, what the run is doing right now, as one line. */ +function liveLine( + steps: readonly ActivityStep[], + labels: ActivityTriggerLabels, +): string { + const running = runningTools(steps); + const [only] = running; + if (running.length > 1) return `${labels.runningMany(running.length)}…`; + if (only !== undefined) return `${only.name}…`; + return steps.at(-1)?.kind === "reasoning" + ? `${labels.thinking}…` + : `${labels.working}…`; +} + +/** The run's one line, which opens and closes it. */ +export function ActivityTrigger({ + label, + labels: labelOverrides, + className, +}: ActivityTriggerProps) { + const { steps, streaming, open, toggle, contentId } = + useActivityContext("ActivityTrigger"); + const labels = { ...TRIGGER_LABELS, ...labelOverrides }; + const failed = countFailedTools(steps); + const line = + label ?? (streaming ? liveLine(steps, labels) : summarizeActivity(steps)); + return ( + + ); +} + +export type ActivityContentLabels = { + readonly showEarlier: (count: number) => string; +}; + +const CONTENT_LABELS: ActivityContentLabels = { + showEarlier: (count) => + `Show ${count} earlier ${count === 1 ? "step" : "steps"}`, +}; + +export type ActivityContentProps = { + /** + * Renders one step. Defaults to `Reasoning`, `Tool` or `ToolGroup` by + * kind; a call with a settled approval shows its outcome. + */ + readonly renderStep?: + | ((step: ActivityStep, index: number) => ReactNode) + | undefined; + readonly labels?: Partial | undefined; + readonly className?: string | undefined; +}; + +function defaultStep(step: ActivityStep): ReactNode { + switch (step.kind) { + case "reasoning": + return ; + case "tool-trace": + return ; + case "tool-batch": + return ; + } +} + +function stepKey(step: ActivityStep, index: number): string { + switch (step.kind) { + case "reasoning": + return `reasoning-${index}`; + case "tool-trace": + return `tool-${step.toolCallId}`; + case "tool-batch": + return `batch-${step.id}`; + } +} + +/** The run's steps, while open: the latest ten, with a control for the rest. */ +export function ActivityContent({ + renderStep = defaultStep, + labels: labelOverrides, + className, +}: ActivityContentProps) { + const { steps, open, contentId } = useActivityContext("ActivityContent"); + if (!open) return null; + return ( + + ); +} + +// Its own component so the "show all" choice resets whenever the run closes. +function ActivitySteps({ + id, + steps, + renderStep, + labels, + className, +}: { + readonly id: string; + readonly steps: readonly ActivityStep[]; + readonly renderStep: (step: ActivityStep, index: number) => ReactNode; + readonly labels: ActivityContentLabels; + readonly className: string | undefined; +}) { + const [showAll, setShowAll] = useState(false); + const listRef = useRef(null); + const revealed = useRef(false); + const hidden = showAll ? 0 : Math.max(0, steps.length - SHOWN_STEPS); + + // The control disappears once pressed, so focus moves to the first step + // it revealed rather than dropping to the page. + useLayoutEffect(() => { + if (!revealed.current) return; + revealed.current = false; + listRef.current?.querySelector("li[data-step]")?.focus(); + }, [showAll]); + + return ( +
    + {hidden > 0 ? ( +
  1. + +
  2. + ) : null} + {steps.slice(hidden).map((step, offset) => { + const index = hidden + offset; + return ( +
  3. + {renderStep(step, index)} +
  4. + ); + })} +
+ ); +} diff --git a/src/ui/reasoning.tsx b/src/ui/reasoning.tsx new file mode 100644 index 00000000..40e48ffb --- /dev/null +++ b/src/ui/reasoning.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Brain } from "lucide-react"; +import { createContext, useContext, useId } from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import type { PartReasoning } from "../lib/chat-parts.js"; +import { summarizeActivity } from "../lib/conversation-activity.js"; +import { cn } from "../lib/utils.js"; + +type ReasoningContextValue = { + readonly part: PartReasoning; + readonly streaming: boolean; + readonly open: boolean; + readonly toggle: () => void; + readonly triggerId: string; + readonly contentId: string; +}; + +const ReasoningContext = createContext(null); + +function useReasoningContext(part: string): ReasoningContextValue { + const context = useContext(ReasoningContext); + if (context === null) throw new Error(`${part} must be inside a Reasoning.`); + return context; +} + +export type ReasoningProps = Omit, "part"> & { + readonly part: PartReasoning; + /** The thinking is still arriving. */ + readonly streaming?: boolean | undefined; + /** Controlled open state. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + readonly defaultOpen?: boolean | undefined; + readonly onOpenChange?: ((open: boolean) => void) | undefined; +}; + +/** + * An agent's thinking, folded to one line: how long it thought and the start + * of what it thought. Opened, the whole text in a box that scrolls rather + * than grows. Closed by default, streaming or not, so the transcript never + * jumps under the reader. + * + * Without children it renders `ReasoningTrigger` and `ReasoningContent`. + * Renders nothing for finished thinking with no text. + */ +export function Reasoning({ + part, + streaming = false, + open: openProp, + defaultOpen = false, + onOpenChange, + className, + children, + ...props +}: ReasoningProps) { + const triggerId = useId(); + const contentId = useId(); + const [open, setOpen] = useControllableState({ + value: openProp, + defaultValue: defaultOpen, + onChange: onOpenChange, + name: "Reasoning", + }); + + if (!streaming && part.text.trim().length === 0) return null; + + return ( + setOpen(!open), + triggerId, + contentId, + }} + > +
+ {children ?? ( + <> + + + + )} +
+
+ ); +} + +export type ReasoningTriggerProps = { + /** Replaces "Thought for 4s" / "Thought". */ + readonly label?: ReactNode; + readonly streamingLabel?: ReactNode; + readonly className?: string | undefined; +}; + +/** The thinking's one line, which opens and closes it. */ +export function ReasoningTrigger({ + label, + streamingLabel = "Thinking…", + className, +}: ReasoningTriggerProps) { + const { part, streaming, open, toggle, triggerId, contentId } = + useReasoningContext("ReasoningTrigger"); + // A single reasoning step summarises as "Thought for 4s", or "Thought" + // while its duration is unknown. + const summary = streaming + ? streamingLabel + : (label ?? summarizeActivity([part])); + return ( + + ); +} + +export type ReasoningContentProps = { + /** Renders the thinking, e.g. as markdown. Defaults to plain text. */ + readonly render?: ((text: string) => ReactNode) | undefined; + readonly className?: string | undefined; +}; + +/** The whole thinking, while open. */ +export function ReasoningContent({ render, className }: ReasoningContentProps) { + const { part, streaming, open, triggerId, contentId } = + useReasoningContext("ReasoningContent"); + if (!open) return null; + return ( +
+ {render === undefined ? part.text : render(part.text)} +
+ ); +} diff --git a/src/ui/tool-approval.test.tsx b/src/ui/tool-approval.test.tsx new file mode 100644 index 00000000..6d2b179c --- /dev/null +++ b/src/ui/tool-approval.test.tsx @@ -0,0 +1,274 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, test } from "bun:test"; + +import type { PartTool, ToolApprovalRecord } from "../lib/conversation.js"; +import { ToolApproval, type ToolApprovalDecision } from "./tool-approval.js"; + +type Mounted = { + container: HTMLElement; + render: (node: React.ReactElement) => void; + unmount: () => void; +}; + +function mount(node: React.ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + render: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function record( + status: ToolApprovalRecord["status"], + scope: ToolApprovalRecord["scope"] = null, +): ToolApprovalRecord { + return { id: "approval-1", status, scope, timeoutAt: null }; +} + +function tool(overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: "call-1", + name: "mail__send", + status: "approval-requested", + input: { to: "team" }, + approval: record("pending"), + ...overrides, + }; +} + +/** A call with no approval record. */ +function unrecorded(status: PartTool["status"]): PartTool { + return { + kind: "tool-trace", + toolCallId: "call-1", + name: "mail__send", + status, + input: { to: "team" }, + }; +} + +function button(container: HTMLElement, 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; +} + +describe("ToolApproval", () => { + const cases: readonly [string, ToolApprovalDecision][] = [ + ["Approve", { outcome: "approved", scope: "once" }], + ["Approve always", { outcome: "approved", scope: "always" }], + ["Deny", { outcome: "rejected", scope: "once" }], + ]; + + for (const [name, decision] of cases) { + test(`${name} reports its decision once`, () => { + const decisions: ToolApprovalDecision[] = []; + const mounted = mount( + decisions.push(d)} />, + ); + act(() => button(mounted.container, name).click()); + expect(decisions).toEqual([decision]); + mounted.unmount(); + }); + } + + test("pending shows the question, the call and its input on the wash", () => { + const mounted = mount( + {}}> +

Send the renewal summary to the team.

+
, + ); + const section = mounted.container.querySelector("section"); + expect(section?.className).toContain("bg-attention-wash"); + expect(section?.getAttribute("aria-labelledby")).not.toBeNull(); + expect(mounted.container.textContent).toContain("Needs your approval"); + expect(mounted.container.textContent).toContain("mail__send"); + expect(mounted.container.textContent).toContain('"to": "team"'); + expect(mounted.container.textContent).toContain( + "Send the renewal summary to the team.", + ); + mounted.unmount(); + }); + + test("disabled holds decisions but keeps the buttons focusable", () => { + const decisions: ToolApprovalDecision[] = []; + const mounted = mount( + decisions.push(d)} + />, + ); + const approve = button(mounted.container, "Approve"); + expect(approve.disabled).toBe(false); + expect(approve.getAttribute("aria-disabled")).toBe("true"); + act(() => approve.click()); + expect(decisions).toEqual([]); + mounted.unmount(); + }); + + test("an error is announced", () => { + const mounted = mount( + {}} + />, + ); + expect(mounted.container.querySelector("[role='alert']")?.textContent).toBe( + "Could not save.", + ); + mounted.unmount(); + }); + + const resolved: readonly [ToolApprovalRecord, string][] = [ + [record("approved", "once"), "Approved once"], + [record("approved", "always"), "Approved always"], + [record("approved"), "Approved"], + [record("rejected", "once"), "Denied"], + [record("rejected", "always"), "Always denied"], + [record("timeout"), "Timed out"], + [record("expired"), "Expired"], + ]; + + for (const [approval, line] of resolved) { + test(`${approval.status}/${approval.scope} renders "${line}" and no buttons`, () => { + const mounted = mount( + {}} + />, + ); + const root = mounted.container.querySelector( + "[data-slot='tool-approval']", + ); + expect(root?.getAttribute("data-status")).toBe(approval.status); + expect(root?.querySelector("span")?.textContent).toBe(line); + expect(root?.textContent).toContain("mail__send"); + expect(mounted.container.querySelector("button")).toBeNull(); + mounted.unmount(); + }); + } + + test("the record decides over the call's status, both ways", () => { + const settled = mount( + {}} + />, + ); + expect(settled.container.querySelector("button")).toBeNull(); + expect(settled.container.textContent).toContain("Timed out"); + settled.unmount(); + + const waiting = mount( + {}} />, + ); + expect(waiting.container.querySelectorAll("button").length).toBe(3); + waiting.unmount(); + }); + + test("without a record, the call's own status decides", () => { + const asking = mount( + {}} + />, + ); + expect(asking.container.querySelectorAll("button").length).toBe(3); + asking.unmount(); + + const none = mount( + {}} + />, + ); + expect(none.container.innerHTML).toBe(""); + none.unmount(); + }); + + test("labels can be replaced", () => { + const mounted = mount( + {}} + />, + ); + button(mounted.container, "Allow"); + button(mounted.container, "Refuse"); + mounted.unmount(); + }); + + test("focus moves to the resolved line when the record settles", () => { + const mounted = mount( {}} />); + const approve = button(mounted.container, "Approve"); + act(() => approve.focus()); + // The host records the decision; the button that held focus goes away. + mounted.render( + {}} + />, + ); + const root = mounted.container.querySelector("[data-slot='tool-approval']"); + expect(approve.isConnected).toBe(false); + expect(document.activeElement).toBe(root); + mounted.unmount(); + }); + + test("focus dropped to the page is left alone when the record settles", () => { + const mounted = mount( {}} />); + const input = mounted.container.querySelector( + "[role='group'][tabindex='0']", + ); + if (input === null) throw new Error("expected the input box"); + act(() => input.focus()); + // A click on blank page: focus goes to nothing, not to another element. + act(() => input.blur()); + expect(document.activeElement).toBe(document.body); + mounted.render( + {}} + />, + ); + expect(document.activeElement).toBe(document.body); + mounted.unmount(); + }); + + test("focus elsewhere is left alone when the record settles", () => { + const outside = document.createElement("input"); + document.body.appendChild(outside); + const mounted = mount( {}} />); + act(() => button(mounted.container, "Approve").focus()); + act(() => outside.focus()); + mounted.render( + {}} + />, + ); + expect(document.activeElement).toBe(outside); + mounted.unmount(); + outside.remove(); + }); +}); diff --git a/src/ui/tool-approval.tsx b/src/ui/tool-approval.tsx new file mode 100644 index 00000000..455c5a3c --- /dev/null +++ b/src/ui/tool-approval.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { + Ban, + Check, + CheckCheck, + Hourglass, + ShieldAlert, + TimerOff, + X, +} from "lucide-react"; +import { useCallback, useId, useLayoutEffect, useRef } from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { isAwaitingApproval } from "../lib/conversation-activity.js"; +import type { + PartTool, + ToolApprovalRecord, + ToolApprovalScope, +} from "../lib/conversation.js"; +import { formatToolValue } from "../lib/tool-display.js"; +import { cn } from "../lib/utils.js"; +import { Button } from "./button.js"; + +/** A person's answer to a pending approval. Deny is always `once`. */ +export type ToolApprovalDecision = { + readonly outcome: "approved" | "rejected"; + readonly scope: ToolApprovalScope; +}; + +export type ToolApprovalLabels = { + readonly title: string; + readonly input: string; + readonly approve: string; + readonly approveAlways: string; + readonly deny: string; + readonly approvedOnce: string; + readonly approvedAlways: string; + /** Approved, with no scope on the record. */ + readonly approved: string; + readonly denied: string; + readonly deniedAlways: string; + readonly timedOut: string; + readonly expired: string; +}; + +const LABELS: ToolApprovalLabels = { + title: "Needs your approval", + input: "Input", + approve: "Approve", + approveAlways: "Approve always", + deny: "Deny", + approvedOnce: "Approved once", + approvedAlways: "Approved always", + approved: "Approved", + denied: "Denied", + deniedAlways: "Always denied", + timedOut: "Timed out", + expired: "Expired", +}; + +export type ToolApprovalProps = Omit< + ComponentProps<"div">, + "children" | "part" +> & { + readonly part: PartTool; + /** + * Called once per click with the person's decision. A fast double click + * can call it twice, so record decisions idempotently by approval id. + */ + readonly onDecide: (decision: ToolApprovalDecision) => void; + /** Renders the call's input. Defaults to plain text or indented JSON. */ + readonly renderInput?: ((input: unknown) => ReactNode) | undefined; + /** Holds the buttons while the host records a decision. They stay focusable. */ + readonly disabled?: boolean | undefined; + /** Why the last decision could not be recorded. */ + readonly error?: ReactNode; + readonly labels?: Partial | undefined; + /** The host's own account of what is being asked, above the input. */ + readonly children?: ReactNode; +}; + +/** + * A tool call's approval. While the call waits on a person it offers Approve, + * Approve always and Deny on the attention wash; once its record is settled + * it is one quiet line saying what happened. Waiting is decided by the + * approval record when there is one (`isAwaitingApproval`), and the outcome + * is read from the record alone. A call with no record that is not waiting + * renders nothing. + * + * When the record settles while focus was inside and nothing else has taken + * it, focus moves to the resolved line instead of dropping to the page. + */ +export function ToolApproval({ + part, + onDecide, + renderInput, + disabled = false, + error, + labels: labelOverrides, + children, + ref, + className, + onFocus, + onBlur, + ...props +}: ToolApprovalProps) { + const labels = { ...LABELS, ...labelOverrides }; + const titleId = useId(); + const inputTitleId = useId(); + const awaiting = isAwaitingApproval(part); + const rootRef = useRef(null); + const focusInside = useRef(false); + const wasAwaiting = useRef(awaiting); + + useLayoutEffect(() => { + if (wasAwaiting.current && !awaiting && focusInside.current) { + const active = document.activeElement; + if (active === null || active === document.body) rootRef.current?.focus(); + } + wasAwaiting.current = awaiting; + }, [awaiting]); + + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); + + if (!awaiting && part.approval === undefined) return null; + + const decide = (decision: ToolApprovalDecision) => () => { + if (!disabled) onDecide(decision); + }; + + return ( +
{ + focusInside.current = true; + onFocus?.(event); + }} + onBlur={(event) => { + // Focus leaving the card, to another element or to nothing (a click + // on blank page), ends "inside". The decided button going away with + // the record does not reach here: React ignores blur while it + // commits, so the flag survives until the layout effect reads it. + const next = event.relatedTarget; + if (next === null || !event.currentTarget.contains(next)) + focusInside.current = false; + onBlur?.(event); + }} + className={cn("min-w-0 text-sm outline-offset-2", className)} + {...props} + > + {awaiting ? ( +
+
+ +

+ {labels.title} +

+ + {part.name} + +
+ {children} + {part.input === undefined ? null : ( +
+

+ {labels.input} +

+
+ {renderInput === undefined + ? formatToolValue(part.input) + : renderInput(part.input)} +
+
+ )} + {error === undefined || error === null ? null : ( +

+ {error} +

+ )} +
+ + + +
+
+ ) : part.approval === undefined ? null : ( + + )} +
+ ); +} + +function ResolvedLine({ + record, + name, + labels, +}: { + readonly record: ToolApprovalRecord; + readonly name: string; + readonly labels: ToolApprovalLabels; +}) { + const { icon, text } = resolved(record, labels); + return ( +

+ {icon} + {text} + {name} +

+ ); +} + +function resolved( + record: ToolApprovalRecord, + labels: ToolApprovalLabels, +): { readonly icon: ReactNode; readonly text: string } { + const icon = "size-3.5 shrink-0"; + switch (record.status) { + case "approved": + if (record.scope === "always") + return { + icon: , + text: labels.approvedAlways, + }; + return { + icon: , + text: record.scope === "once" ? labels.approvedOnce : labels.approved, + }; + case "rejected": + if (record.scope === "always") + return { + icon: , + text: labels.deniedAlways, + }; + return { + icon: , + text: labels.denied, + }; + case "timeout": + return { + icon: , + text: labels.timedOut, + }; + case "expired": + return { + icon: , + text: labels.expired, + }; + case "pending": + // Unreachable: a pending record is waiting and renders the buttons. + return { icon: null, text: "" }; + } +} diff --git a/src/ui/tool-group.tsx b/src/ui/tool-group.tsx new file mode 100644 index 00000000..4804d39d --- /dev/null +++ b/src/ui/tool-group.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { ChevronRight, Layers } from "lucide-react"; +import { useId } from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { isAwaitingApproval } from "../lib/conversation-activity.js"; +import type { PartTool, PartToolBatch } from "../lib/conversation.js"; +import { countFailedTools, displayedToolStatus } from "../lib/tool-display.js"; +import { cn } from "../lib/utils.js"; +import { Tool } from "./tool.js"; +import { ToolApproval, type ToolApprovalDecision } from "./tool-approval.js"; + +export type ToolGroupLabels = { + /** Every call has finished. */ + readonly ran: (count: number) => string; + /** Some calls are still queued or running. */ + readonly running: (count: number, done: number) => string; + /** None is queued or running, but some wait on a person. */ + readonly held: (count: number, done: number) => string; + /** Follows `running` or `held` while calls wait on a person. */ + readonly waiting: (count: number) => string; + readonly failed: (count: number) => string; +}; + +const LABELS: ToolGroupLabels = { + ran: (count) => `Ran ${count} at once`, + running: (count, done) => `Running ${count} at once · ${done} done`, + held: (count, done) => `${count} at once · ${done} done`, + waiting: (count) => `${count} waiting for approval`, + failed: (count) => `${count} failed`, +}; + +export type ToolGroupProps = Omit< + ComponentProps<"div">, + "children" | "part" +> & { + readonly part: PartToolBatch; + /** Controlled open state. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + /** + * Seeds the open state. Left out, the group is open exactly while one of + * its calls waits on a person, until a person opens or closes it. + */ + readonly defaultOpen?: boolean | undefined; + readonly onOpenChange?: ((open: boolean) => void) | undefined; + /** A decision on one of the group's calls that waits on a person. */ + readonly onDecide?: + | ((tool: PartTool, decision: ToolApprovalDecision) => void) + | undefined; + /** + * Renders one call. Defaults to `ToolApproval` for a call waiting on a + * person (when `onDecide` is given) and `Tool` for the rest. + */ + readonly renderTool?: ((tool: PartTool) => ReactNode) | undefined; + readonly labels?: Partial | undefined; +}; + +/** Tool calls that ran at the same time: one row saying so, the calls beneath. */ +export function ToolGroup({ + part, + open: openProp, + defaultOpen, + onOpenChange, + onDecide, + renderTool, + labels: labelOverrides, + className, + ...props +}: ToolGroupProps) { + const labels = { ...LABELS, ...labelOverrides }; + const listId = useId(); + const awaiting = part.tools.some(isAwaitingApproval); + // `null` means nobody has opened or closed it yet, so a waiting call decides. + const [chosen, setChosen] = useControllableState({ + value: openProp, + defaultValue: defaultOpen ?? null, + onChange: + onOpenChange === undefined + ? undefined + : (next) => { + if (next !== null) onOpenChange(next); + }, + name: "ToolGroup", + }); + const open = chosen ?? awaiting; + + const statuses = part.tools.map(displayedToolStatus); + const active = statuses.filter( + (status) => status === "pending" || status === "running", + ).length; + const waiting = statuses.filter( + (status) => status === "approval-requested", + ).length; + const failed = countFailedTools(part.tools); + const count = part.tools.length; + + const render = + renderTool ?? + ((tool: PartTool) => + onDecide !== undefined && isAwaitingApproval(tool) ? ( + onDecide(tool, decision)} + /> + ) : ( + + )); + + return ( +
+ + {open ? ( +
    + {part.tools.map((tool) => ( +
  1. + {render(tool)} +
  2. + ))} +
+ ) : null} +
+ ); +} diff --git a/src/ui/tool.test.tsx b/src/ui/tool.test.tsx new file mode 100644 index 00000000..2c0e4c89 --- /dev/null +++ b/src/ui/tool.test.tsx @@ -0,0 +1,360 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, test } from "bun:test"; + +import type { ToolTraceStatus } from "../lib/chat-parts.js"; +import type { PartTool, PartToolBatch } from "../lib/conversation.js"; +import { Tool, ToolHeader, ToolInput, ToolOutput } from "./tool.js"; +import { ToolGroup } from "./tool-group.js"; + +type Mounted = { + container: HTMLElement; + render: (node: React.ReactElement) => void; + unmount: () => void; +}; + +function mount(node: React.ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + render: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function tool(overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: "call-1", + name: "files__read", + status: "output-available", + input: { path: "a.pdf" }, + output: "Renews next year", + ...overrides, + }; +} + +function header(container: HTMLElement): HTMLElement { + const element = container.querySelector( + "[data-slot='tool-header']", + ); + if (element === null) throw new Error("expected a tool header"); + return element; +} + +function click(element: Element | null) { + if (!(element instanceof HTMLElement)) throw new Error("expected an element"); + act(() => element.click()); +} + +const STATUSES: readonly [ToolTraceStatus, string, string][] = [ + ["pending", "Queued", "lucide-circle-dashed"], + ["running", "Running", "lucide-loader-circle"], + ["output-available", "Done", "lucide-check"], + ["error", "Failed", "lucide-circle-alert"], + ["approval-requested", "Needs approval", "lucide-shield-alert"], + ["output-denied", "Not run", "lucide-ban"], +]; + +describe("Tool", () => { + for (const [status, label, icon] of STATUSES) { + test(`${status} renders its status word and icon`, () => { + const mounted = mount(); + const row = header(mounted.container); + expect( + mounted.container.firstElementChild?.getAttribute("data-status"), + ).toBe(status); + expect(row.textContent).toContain(label); + expect(row.textContent).toContain("files__read"); + expect(row.querySelector(`svg.${icon}`)).not.toBeNull(); + mounted.unmount(); + }); + } + + test("an error opens and shows the output under an Error title", () => { + const mounted = mount( + , + ); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "true", + ); + const output = mounted.container.querySelector("[data-slot='tool-output']"); + expect(output?.textContent).toContain("Error"); + expect(output?.textContent).toContain("Password protected"); + mounted.unmount(); + }); + + test("a call that fails after mounting opens, until a person chooses", () => { + const mounted = mount( + , + ); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "false", + ); + mounted.render( + , + ); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "true", + ); + click(header(mounted.container)); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "false", + ); + mounted.unmount(); + }); + + test("a finished call starts closed and opens onto input and output", () => { + const mounted = mount(); + expect( + mounted.container.querySelector("[data-slot='tool-input']"), + ).toBeNull(); + click(header(mounted.container)); + expect( + mounted.container.querySelector("[data-slot='tool-input']")?.textContent, + ).toContain('"path": "a.pdf"'); + expect( + mounted.container.querySelector("[data-slot='tool-output']")?.textContent, + ).toContain("Renews next year"); + mounted.unmount(); + }); + + test("opened, the toggle controls the input and output it shows", () => { + const first = mount(); + const second = mount(); + const toggle = header(first.container); + expect(toggle.hasAttribute("aria-controls")).toBe(false); + click(toggle); + const ids = (toggle.getAttribute("aria-controls") ?? "").split(" "); + expect(ids.length).toBe(2); + expect(ids.map((id) => document.getElementById(id)?.dataset.slot)).toEqual([ + "tool-input", + "tool-output", + ]); + click(header(second.container)); + expect(header(second.container).getAttribute("aria-controls")).not.toBe( + toggle.getAttribute("aria-controls"), + ); + first.unmount(); + second.unmount(); + }); + + test("a call with neither input nor output has no toggle", () => { + const mounted = mount( + , + ); + expect(header(mounted.container).tagName).toBe("DIV"); + expect(mounted.container.querySelector("button")).toBeNull(); + mounted.unmount(); + }); + + test("input and output go through the caller's render props", () => { + const mounted = mount( + + + in:{JSON.stringify(input)}} /> + out:{String(output)}} + /> + , + ); + expect(mounted.container.textContent).toContain("Read the agreement"); + expect(mounted.container.querySelector("em")?.textContent).toBe( + 'in:{"path":"a.pdf"}', + ); + expect(mounted.container.querySelector("strong")?.textContent).toBe( + "out:Renews next year", + ); + mounted.unmount(); + }); + + test("status words can be replaced", () => { + const mounted = mount( + + + , + ); + expect(header(mounted.container).textContent).toContain("En cours"); + mounted.unmount(); + }); + + test("controlled open follows the prop and reports clicks", () => { + const changes: boolean[] = []; + const mounted = mount( + changes.push(open)} + />, + ); + click(header(mounted.container)); + expect(changes).toEqual([true]); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "false", + ); + mounted.render( {}} />); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "true", + ); + mounted.unmount(); + }); + + test("a settled approval shows its outcome under the header", () => { + const mounted = mount( + , + ); + expect(mounted.container.textContent).toContain("Approved always"); + expect( + mounted.container.querySelector("button[aria-expanded]"), + ).not.toBeNull(); + expect(mounted.container.textContent).not.toContain("Approve always"); + mounted.unmount(); + }); + + test("parts outside a Tool throw", () => { + const original = console.error; + console.error = () => {}; + expect(() => mount()).toThrow( + "ToolHeader must be inside a Tool.", + ); + console.error = original; + }); +}); + +function batch(tools: readonly PartTool[]): PartToolBatch { + return { kind: "tool-batch", id: "batch-1", tools }; +} + +describe("ToolGroup", () => { + const finished = batch([ + tool({ toolCallId: "a" }), + tool({ toolCallId: "b", status: "error" }), + tool({ toolCallId: "c" }), + ]); + + test("summarises a finished batch and its failures, closed", () => { + const mounted = mount(); + const button = mounted.container.querySelector("button"); + expect(button?.textContent).toContain("Ran 3 at once"); + expect(button?.textContent).toContain("1 failed"); + expect(button?.getAttribute("aria-expanded")).toBe("false"); + expect( + mounted.container.querySelectorAll("[data-slot='tool']").length, + ).toBe(0); + click(button); + expect( + mounted.container.querySelectorAll("[data-slot='tool']").length, + ).toBe(3); + mounted.unmount(); + }); + + test("counts what is done while calls still run", () => { + const mounted = mount( + , + ); + expect(mounted.container.textContent).toContain( + "Running 3 at once · 1 done", + ); + mounted.unmount(); + }); + + test("a call waiting on a person is not counted as done", () => { + const pending = { + id: "x", + status: "pending", + scope: null, + timeoutAt: null, + } as const; + const held = mount( + , + ); + const heldHeader = held.container.querySelector( + "[data-slot='tool-group-header']", + ); + expect(heldHeader?.textContent).toBe( + "2 at once · 1 done · 1 waiting for approval", + ); + expect(heldHeader?.textContent).not.toContain("Ran"); + held.unmount(); + + const busy = mount( + `${count} en attente` }} + />, + ); + expect( + busy.container.querySelector("[data-slot='tool-group-header']") + ?.textContent, + ).toBe("Running 3 at once · 1 done · 1 en attente"); + busy.unmount(); + }); + + test("opens on a waiting call and offers its decision", () => { + const decisions: string[] = []; + const mounted = mount( + + decisions.push(`${part.toolCallId}:${decision.outcome}`) + } + />, + ); + const approve = [...mounted.container.querySelectorAll("button")].find( + (button) => button.textContent === "Approve", + ); + click(approve ?? null); + expect(decisions).toEqual(["b:approved"]); + mounted.unmount(); + }); +}); diff --git a/src/ui/tool.tsx b/src/ui/tool.tsx new file mode 100644 index 00000000..a210d61d --- /dev/null +++ b/src/ui/tool.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { + Ban, + Check, + ChevronRight, + CircleAlert, + CircleDashed, + LoaderCircle, + ShieldAlert, +} from "lucide-react"; +import { createContext, useContext, useId } from "react"; +import type { ComponentProps, ReactNode } from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import type { ToolTraceStatus } from "../lib/chat-parts.js"; +import { isAwaitingApproval } from "../lib/conversation-activity.js"; +import type { PartTool } from "../lib/conversation.js"; +import { displayedToolStatus, formatToolValue } from "../lib/tool-display.js"; +import { cn } from "../lib/utils.js"; +import { ToolApproval } from "./tool-approval.js"; + +type ToolContextValue = { + readonly part: PartTool; + readonly status: ToolTraceStatus; + readonly open: boolean; + readonly toggle: () => void; + readonly hasDetail: boolean; + readonly detailId: string; +}; + +const ToolContext = createContext(null); + +function useToolContext(part: string): ToolContextValue { + const context = useContext(ToolContext); + if (context === null) throw new Error(`${part} must be inside a Tool.`); + return context; +} + +export type ToolProps = Omit, "part"> & { + readonly part: PartTool; + /** Controlled open state. Pair with `onOpenChange`. */ + readonly open?: boolean | undefined; + /** + * Seeds the open state. Left out, the call is open exactly while it shows + * `error`, including when it fails after it mounted, until a person opens + * or closes it. + */ + readonly defaultOpen?: boolean | undefined; + readonly onOpenChange?: ((open: boolean) => void) | undefined; +}; + +/** + * One tool call: a header row that opens onto its input and output. + * + * Without children it renders `ToolHeader`, the outcome of a settled + * approval, `ToolInput` and `ToolOutput`. A call waiting on a person is + * `ToolApproval`'s job; here it only reads "Needs approval". + */ +export function Tool({ + part, + open: openProp, + defaultOpen, + onOpenChange, + className, + children, + ...props +}: ToolProps) { + const detailId = useId(); + const status = displayedToolStatus(part); + // `null` means nobody has opened or closed it yet, so the status decides. + const [chosen, setChosen] = useControllableState({ + value: openProp, + defaultValue: defaultOpen ?? null, + onChange: + onOpenChange === undefined + ? undefined + : (next) => { + if (next !== null) onOpenChange(next); + }, + name: "Tool", + }); + const open = chosen ?? status === "error"; + const hasDetail = part.input !== undefined || part.output !== undefined; + const settled = part.approval !== undefined && !isAwaitingApproval(part); + + return ( + setChosen(!open), + hasDetail, + detailId, + }} + > +
+ {children ?? ( + <> + + {settled ? ( + + ) : null} + + + + )} +
+
+ ); +} + +// A settled approval only shows its outcome; it never offers a decision. +function ignoreDecision() {} + +const STATUS_LABEL: Record = { + pending: "Queued", + running: "Running", + "output-available": "Done", + error: "Failed", + "approval-requested": "Needs approval", + "output-denied": "Not run", +}; + +function StatusIcon({ status }: { readonly status: ToolTraceStatus }) { + const className = "size-3.5 shrink-0"; + switch (status) { + case "pending": + return ( + + ); + case "running": + return ( + + ); + case "output-available": + return ; + case "error": + return ( + + ); + case "approval-requested": + return ( + + ); + case "output-denied": + return ( + + ); + } +} + +export type ToolHeaderProps = { + /** Human phrasing of what the call did, shown before the raw tool name. */ + readonly label?: ReactNode; + /** Overrides the status words ("Queued", "Running", "Done", …). */ + readonly statusLabels?: Partial> | undefined; + readonly className?: string | undefined; +}; + +/** + * The call's row: its status as an icon and a word, the label and the raw + * tool name. It toggles the input and output, and is plain text when the + * call has neither. + */ +export function ToolHeader({ + label, + statusLabels, + className, +}: ToolHeaderProps) { + const { part, status, open, toggle, hasDetail, detailId } = + useToolContext("ToolHeader"); + // The ids `ToolInput` and `ToolOutput` render under while open. + const controls = [ + part.input === undefined ? null : `${detailId}-input`, + part.output === undefined ? null : `${detailId}-output`, + ] + .filter((id) => id !== null) + .join(" "); + const statusText = statusLabels?.[status] ?? STATUS_LABEL[status]; + const row = cn( + "flex w-full min-w-0 items-center gap-2 rounded-md px-1 py-1 text-start", + className, + ); + const content = ( + <> + + + {label === undefined ? null : {label}} + + {part.name} + + + + {statusText} + + + ); + + if (!hasDetail) { + return ( +
+ {content} +
+ ); + } + return ( + + ); +} + +export type ToolDetailProps = { + /** Renders the value, e.g. as highlighted code. Defaults to plain text or indented JSON. */ + readonly render?: ((value: unknown) => ReactNode) | undefined; + readonly title?: string | undefined; + readonly className?: string | undefined; +}; + +export type ToolInputProps = ToolDetailProps; + +/** The call's arguments, while the call is open. */ +export function ToolInput({ + render, + title = "Input", + className, +}: ToolInputProps) { + const { part, open, detailId } = useToolContext("ToolInput"); + if (!open || part.input === undefined) return null; + return ( + + {render === undefined ? formatToolValue(part.input) : render(part.input)} + + ); +} + +export type ToolOutputProps = ToolDetailProps & { + /** The title when the call failed. */ + readonly errorTitle?: string | undefined; +}; + +/** What the call returned, or why it failed, while the call is open. */ +export function ToolOutput({ + render, + title = "Output", + errorTitle = "Error", + className, +}: ToolOutputProps) { + const { part, status, open, detailId } = useToolContext("ToolOutput"); + if (!open || part.output === undefined) return null; + return ( + + {render === undefined + ? formatToolValue(part.output) + : render(part.output)} + + ); +} + +function ToolDetail({ + slot, + id, + title, + className, + children, +}: { + readonly slot: string; + readonly id: string; + readonly title: string; + readonly className: string | undefined; + readonly children: ReactNode; +}) { + const titleId = `${id}-title`; + return ( +
+

+ {title} +

+ {/* Scrolls in its own box, so a long result never stretches the + transcript; focusable so the keyboard can scroll it. */} +
+ {children} +
+
+ ); +} diff --git a/stories/chat/activity.stories.tsx b/stories/chat/activity.stories.tsx new file mode 100644 index 00000000..b215f224 --- /dev/null +++ b/stories/chat/activity.stories.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; + +import type { PartReasoning } from "../../src/lib/chat-parts.js"; +import { + groupActivity, + type ActivityStep, +} from "../../src/lib/conversation-activity.js"; +import type { + ConversationPart, + PartTool, + ToolApprovalRecord, +} from "../../src/lib/conversation.js"; +import { Activity } from "../../src/ui/activity.js"; +import { Button } from "../../src/ui/button.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" }; + +function partsOf(id: string): readonly ConversationPart[] { + const message = toolStorm.messages.find((candidate) => candidate.id === id); + if (message === undefined) throw new Error(`No sample message ${id}`); + return message.parts; +} + +/** The first run of a message: its thinking and forty reads. */ +function firstRun(parts: readonly ConversationPart[]): readonly ActivityStep[] { + for (const group of groupActivity(parts)) + if (group.kind === "activity") return group.steps; + 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 = () => ; + +/** A 41-step run opened: the latest ten, with a control for the rest. */ +export const Opened = () => ( +
+ +
+); + +const liveThought: PartReasoning = { + kind: "reasoning", + text: "Six pricing pages to check; fetch them together and compare with last quarter.", +}; + +/** A still frame of a live run: thinking, then calls running at once. */ +export const Streaming = () => ( +
+ + +
+); + +/** Open state lifted to the host. */ +export const Controlled = () => { + const [open, setOpen] = useState(true); + return ( +
+ + +
+ ); +}; + +/** Thinking on its own: done with a duration, without one, live, opened. */ +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. + */ +export const ApprovalFolds = () => { + const [approval, setApproval] = useState({ + id: "approval-fold", + status: "pending", + scope: null, + timeoutAt: null, + }); + const parts: readonly PartTool[] = [ + { + kind: "tool-trace", + toolCallId: "draft", + name: "mail__draft", + status: "output-available", + input: { to: "team", subject: "Renewals this quarter" }, + output: "Draft ready", + }, + { + kind: "tool-trace", + toolCallId: "send", + name: "mail__send", + status: + approval.status === "pending" + ? "approval-requested" + : approval.status === "approved" + ? "output-available" + : "output-denied", + input: { to: "team", subject: "Renewals this quarter" }, + approval, + }, + ]; + return ( +
+ {groupActivity(parts).map((group) => + group.kind === "activity" ? ( + + ) : group.part.kind === "tool-trace" ? ( + + setApproval({ + ...approval, + status: decision.outcome, + scope: decision.scope, + }) + } + /> + ) : null, + )} +
+ ); +}; diff --git a/stories/chat/tool.stories.tsx b/stories/chat/tool.stories.tsx new file mode 100644 index 00000000..1ab44ddd --- /dev/null +++ b/stories/chat/tool.stories.tsx @@ -0,0 +1,289 @@ +import { useState } from "react"; + +import type { ToolTraceStatus } from "../../src/lib/chat-parts.js"; +import type { + ConversationPart, + PartTool, + PartToolBatch, + ToolApprovalRecord, +} from "../../src/lib/conversation.js"; +import { Tool, ToolHeader, ToolInput, ToolOutput } from "../../src/ui/tool.js"; +import { + ToolApproval, + type ToolApprovalDecision, +} from "../../src/ui/tool-approval.js"; +import { ToolGroup } from "../../src/ui/tool-group.js"; +import { toolStorm } from "./samples/conversations.js"; + +export default { title: "Chat / Tool" }; + +function partsOf(id: string): readonly ConversationPart[] { + const message = toolStorm.messages.find((candidate) => candidate.id === id); + if (message === undefined) throw new Error(`No sample message ${id}`); + return message.parts; +} + +function isTool(part: ConversationPart): part is PartTool { + return part.kind === "tool-trace"; +} + +const pendingCall = partsOf("storm-4").filter(isTool).at(-1); +const settledCalls = partsOf("storm-2") + .filter(isTool) + .filter((part) => part.approval !== undefined); +const sampleBatch = partsOf("storm-2").find( + (part): part is PartToolBatch => part.kind === "tool-batch", +); + +function Column({ children }: { readonly children: React.ReactNode }) { + return
{children}
; +} + +const STATUSES: readonly ToolTraceStatus[] = [ + "pending", + "running", + "output-available", + "error", + "approval-requested", + "output-denied", +]; + +/** Every execution status. A failed call opens onto its error. */ +export const EveryStatus = () => ( + + {STATUSES.map((status) => ( + + ))} + +); + +/** Opened, with a label and the caller rendering input and output. */ +export const CustomRender = () => ( + + + + ( + + GET {String(Object.values(input ?? {})[0])} + + )} + /> + ( +
+ {Object.entries(output ?? {}).map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
+ ))} +
+ )} + /> +
+
+); + +/** Nothing to open: the row is plain text. */ +export const NoDetail = () => ( + + + +); + +/** + * A call waiting on a person. Deciding records the outcome on the call the + * way a host would, and the card becomes its resolved line. + */ +export const PendingApproval = () => { + const [approval, setApproval] = useState( + pendingCall?.approval, + ); + if (pendingCall === undefined || approval === undefined) return null; + const decide = (decision: ToolApprovalDecision) => + setApproval({ + ...approval, + status: decision.outcome, + scope: decision.scope, + }); + return ( + + +

Send the renewal summary to the whole team.

+
+
+ ); +}; + +/** The host is recording a decision, and its last attempt failed. */ +export const PendingApprovalError = () => + pendingCall === undefined ? null : ( + + {}} + /> + + ); + +/** Every settled outcome, read from the approval record. */ +export const ResolvedApprovals = () => ( + + {settledCalls.map((part) => ( + {}} /> + ))} + {}} + /> + +); + +/** Settled calls as tools: the outcome sits under each row. */ +export const ResolvedTools = () => ( + + {settledCalls.map((part) => ( + + ))} + +); + +/** Six calls that ran at once, opened. */ +export const Batch = () => + sampleBatch === undefined ? null : ( + + + + ); + +/** A batch still running, with one failure so far. */ +export const BatchRunning = () => ( + + + +); + +/** A batch with a call waiting on a person opens on that call's decision. */ +export const BatchWaiting = () => { + const [approval, setApproval] = useState({ + id: "approval-batch", + status: "pending", + scope: null, + timeoutAt: null, + }); + return ( + + + setApproval({ + ...approval, + status: decision.outcome, + scope: decision.scope, + }) + } + /> + + ); +}; From b6192f0e2d3e1bfd70cbc5beeb4ef958bc9875a0 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 18:24:00 +0545 Subject: [PATCH 02/10] fix(agent): keep focus and the outcome when a group's call is decided A standalone ToolGroup was open only while a call waited, so deciding the last one unmounted the list with the focused button: the outcome never showed and focus fell to the page. With calls still waiting, the decided call re-rendered as a Tool whose fresh ToolApproval never ran its own recovery, so focus fell to the page there too. The group now stays open once a waiting call opened it, until a person closes it, and after a decision moves focus to the next waiting call's first button, else the decided call's outcome, else its own row, the same order the message renderer uses. A Tool opened by an error stays open through a retry that succeeds, so focus in its output is kept. The settled approval line, where focus lands, is now a group named by its text, with a space so it reads "Approved once mail__send" rather than running the outcome into the tool name. An e2e scenario checks a standalone group never leaves focus on the page between frames. --- e2e/fixtures.tsx | 67 ++++++++++++ e2e/tool-approval.spec.ts | 59 ++++++++++ src/ui/tool-approval.test.tsx | 18 ++++ src/ui/tool-approval.tsx | 20 +++- src/ui/tool-group.tsx | 103 ++++++++++++++++-- src/ui/tool.test.tsx | 195 +++++++++++++++++++++++++++++++++- src/ui/tool.tsx | 16 ++- 7 files changed, 463 insertions(+), 15 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index ae0686b9..88ceef00 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -53,6 +53,8 @@ import { groupActivity, type PartTool, type ToolApprovalRecord, + ToolGroup, + type PartToolBatch, } from "@corbits/react-ui"; import { CommandPalette, @@ -1003,6 +1005,70 @@ function ToolApprovalScenario() { ); } +// A batch on its own, two calls waiting on a person. Deciding records the +// outcome on the call; the group stays open and keeps focus inside it. +function ToolGroupApprovalScenario() { + const { log, record } = useEventLog(); + const waitingCall = (id: string, name: string): PartTool => ({ + kind: "tool-trace", + toolCallId: id, + name, + status: "approval-requested", + input: { to: "team" }, + approval: { + id: `approval-${id}`, + status: "pending", + scope: null, + timeoutAt: null, + }, + }); + const [part, setPart] = useState({ + kind: "tool-batch", + id: "batch", + tools: [ + { + kind: "tool-trace", + toolCallId: "read", + name: "files__read", + status: "output-available", + output: "Notes", + }, + waitingCall("mail", "mail__send"), + waitingCall("calendar", "calendar__invite"), + ], + }); + return ( + <> + { + record(`${tool.name}:${decision.outcome}:${decision.scope}`); + setPart((current) => ({ + ...current, + tools: current.tools.map((call) => + call.toolCallId === tool.toolCallId && call.approval !== undefined + ? { + ...call, + status: + decision.outcome === "approved" + ? "output-available" + : "output-denied", + approval: { + ...call.approval, + status: decision.outcome, + scope: decision.scope, + }, + } + : call, + ), + })); + }} + /> + {log} + + ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1046,4 +1112,5 @@ export const scenarios: Record ReactNode> = { "prompt-input-toolbar-dialog": () => , "panel-tabs": () => , "tool-approval": () => , + "tool-group-approval": () => , }; diff --git a/e2e/tool-approval.spec.ts b/e2e/tool-approval.spec.ts index ca026268..7ec2806c 100644 --- a/e2e/tool-approval.spec.ts +++ b/e2e/tool-approval.spec.ts @@ -44,3 +44,62 @@ test("the decision works from the keyboard", async ({ page }) => { "approved:always", ); }); + +test("in a group, keyboard decisions keep focus inside it", async ({ + page, +}) => { + await page.goto("/tool-group-approval"); + const waiting = page.locator( + "[data-slot='tool-approval'][data-status='pending']", + ); + await expect(waiting).toHaveCount(2); + await waiting + .first() + .getByRole("button", { name: "Approve", exact: true }) + .focus(); + // Counts the frames on which focus sat on the page instead of an element. + await page.evaluate(() => { + const watched = window as Window & { bodyFrames?: number }; + watched.bodyFrames = 0; + const tick = () => { + const active = document.activeElement; + if (active === null || active === document.body) + watched.bodyFrames = (watched.bodyFrames ?? 0) + 1; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + const bodyFrames = () => + page.evaluate( + () => (window as Window & { bodyFrames?: number }).bodyFrames, + ); + + await page.keyboard.press("Enter"); + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + "mail__send:approved:once", + ); + await expect(waiting).toHaveCount(1); + await expect( + waiting.getByRole("button", { name: "Approve", exact: true }), + ).toBeFocused(); + + await page.keyboard.press("Enter"); + await expect(waiting).toHaveCount(0); + const header = page.locator("[data-slot='tool-group-header']"); + await expect(header).toHaveAttribute("aria-expanded", "true"); + const outcome = page.getByRole("group", { + name: "Approved once calendar__invite", + }); + await expect(outcome).toBeFocused(); + // Let a few more frames pass before reading the count. + await page.evaluate( + () => + new Promise((resolve) => { + let left = 5; + const step = () => + --left === 0 ? resolve() : requestAnimationFrame(step); + requestAnimationFrame(step); + }), + ); + expect(await bodyFrames()).toBe(0); +}); diff --git a/src/ui/tool-approval.test.tsx b/src/ui/tool-approval.test.tsx index 6d2b179c..0dada256 100644 --- a/src/ui/tool-approval.test.tsx +++ b/src/ui/tool-approval.test.tsx @@ -235,6 +235,24 @@ describe("ToolApproval", () => { mounted.unmount(); }); + test("the resolved line is a named group that reads its outcome, then the call", () => { + const mounted = mount( + {}} + />, + ); + const root = mounted.container.querySelector("[data-slot='tool-approval']"); + expect(root?.getAttribute("role")).toBe("group"); + const name = (root?.getAttribute("aria-labelledby") ?? "") + .split(" ") + .filter((id) => id !== "") + .map((id) => document.getElementById(id)?.textContent ?? "") + .join(" "); + expect(name).toBe("Approved once mail__send"); + mounted.unmount(); + }); + test("focus dropped to the page is left alone when the record settles", () => { const mounted = mount( {}} />); const input = mounted.container.querySelector( diff --git a/src/ui/tool-approval.tsx b/src/ui/tool-approval.tsx index 455c5a3c..099a1d24 100644 --- a/src/ui/tool-approval.tsx +++ b/src/ui/tool-approval.tsx @@ -108,6 +108,7 @@ export function ToolApproval({ const labels = { ...LABELS, ...labelOverrides }; const titleId = useId(); const inputTitleId = useId(); + const outcomeId = useId(); const awaiting = isAwaitingApproval(part); const rootRef = useRef(null); const focusInside = useRef(false); @@ -141,6 +142,9 @@ export function ToolApproval({ ref={setRoot} data-slot="tool-approval" data-status={awaiting ? "pending" : part.approval?.status} + // Settled, the line is where focus lands, so it is named by what it says. + role={awaiting ? undefined : "group"} + aria-labelledby={awaiting ? undefined : outcomeId} tabIndex={-1} onFocus={(event) => { focusInside.current = true; @@ -229,26 +233,36 @@ export function ToolApproval({ ) : part.approval === undefined ? null : ( - + )} ); } function ResolvedLine({ + id, record, name, labels, }: { + readonly id: string; readonly record: ToolApprovalRecord; readonly name: string; readonly labels: ToolApprovalLabels; }) { const { icon, text } = resolved(record, labels); return ( -

+

{icon} - {text} + {text}{" "} {name}

); diff --git a/src/ui/tool-group.tsx b/src/ui/tool-group.tsx index 4804d39d..84c246f5 100644 --- a/src/ui/tool-group.tsx +++ b/src/ui/tool-group.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronRight, Layers } from "lucide-react"; -import { useId } from "react"; +import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; import type { ComponentProps, ReactNode } from "react"; import { useControllableState } from "../hooks/use-controllable-state.js"; @@ -40,8 +40,9 @@ export type ToolGroupProps = Omit< /** Controlled open state. Pair with `onOpenChange`. */ readonly open?: boolean | undefined; /** - * Seeds the open state. Left out, the group is open exactly while one of - * its calls waits on a person, until a person opens or closes it. + * Seeds the open state. Left out, the group opens when one of its calls + * waits on a person and stays open, so the outcome of a decision shows, + * until a person closes it. */ readonly defaultOpen?: boolean | undefined; readonly onOpenChange?: ((open: boolean) => void) | undefined; @@ -57,7 +58,14 @@ export type ToolGroupProps = Omit< readonly labels?: Partial | undefined; }; -/** Tool calls that ran at the same time: one row saying so, the calls beneath. */ +/** + * Tool calls that ran at the same time: one row saying so, the calls beneath. + * + * When a decision settles a call while focus was inside and nothing else has + * taken it, focus moves to the next waiting call's first button, else to the + * decided call's outcome, else to the group's row, instead of dropping to + * the page. + */ export function ToolGroup({ part, open: openProp, @@ -66,7 +74,10 @@ export function ToolGroup({ onDecide, renderTool, labels: labelOverrides, + ref, className, + onFocus, + onBlur, ...props }: ToolGroupProps) { const labels = { ...LABELS, ...labelOverrides }; @@ -84,7 +95,13 @@ export function ToolGroup({ }, name: "ToolGroup", }); - const open = chosen ?? awaiting; + // Once a waiting call opened the group it stays open after the decision, + // so the outcome shows and the focused card is not unmounted with the list. + // Set while rendering (react.dev, "Storing information from previous + // renders"): an effect would run after the list had already gone. + const [autoOpened, setAutoOpened] = useState(awaiting); + if (awaiting && !autoOpened) setAutoOpened(true); + const open = chosen ?? autoOpened; const statuses = part.tools.map(displayedToolStatus); const active = statuses.filter( @@ -96,6 +113,40 @@ export function ToolGroup({ const failed = countFailedTools(part.tools); const count = part.tools.length; + const rootRef = useRef(null); + const focusInside = useRef(false); + // The list item that last held focus: the call a decision was made on. + const focusedItem = useRef(null); + const wasWaiting = useRef(waiting); + + // A decided call is rendered anew (`Tool` in place of `ToolApproval`), so + // its own recovery never runs; the group moves focus instead. A child that + // stayed mounted recovers first, and then focus is no longer on the page. + useLayoutEffect(() => { + const dropped = waiting < wasWaiting.current; + wasWaiting.current = waiting; + if (!dropped || !focusInside.current) return; + const active = document.activeElement; + if (active !== null && active !== document.body) return; + const root = rootRef.current; + if (root === null) return; + const item = focusedItem.current?.isConnected ? focusedItem.current : null; + const target = + nextWaiting(root, item) ?? + item?.querySelector("[data-slot='tool-approval']") ?? + root.querySelector("[data-slot='tool-group-header']"); + target?.focus(); + }, [waiting]); + + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); + const render = renderTool ?? ((tool: PartTool) => @@ -110,9 +161,23 @@ export function ToolGroup({ return (
{ + focusInside.current = true; + onFocus?.(event); + }} + onBlur={(event) => { + // Focus leaving the group, to another element or to nothing, ends + // "inside". A decided card going away does not reach here: React + // ignores blur while it commits, so the layout effect still sees it. + const next = event.relatedTarget; + if (next === null || !event.currentTarget.contains(next)) + focusInside.current = false; + onBlur?.(event); + }} >
); } + +const WAITING = "[data-slot='tool-approval'][data-status='pending']"; + +/** The first waiting call after the decided one, else the first waiting call. */ +function nextWaiting( + root: HTMLElement, + decided: HTMLElement | null, +): HTMLElement | null { + const cards = [...root.querySelectorAll(WAITING)]; + const after = + decided === null + ? undefined + : cards.find( + (card) => + (decided.compareDocumentPosition(card) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0, + ); + return (after ?? cards[0])?.querySelector("button") ?? null; +} diff --git a/src/ui/tool.test.tsx b/src/ui/tool.test.tsx index 2c0e4c89..2b06b06b 100644 --- a/src/ui/tool.test.tsx +++ b/src/ui/tool.test.tsx @@ -1,10 +1,11 @@ -import { act } from "react"; +import { act, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, test } from "bun:test"; import type { ToolTraceStatus } from "../lib/chat-parts.js"; import type { PartTool, PartToolBatch } from "../lib/conversation.js"; import { Tool, ToolHeader, ToolInput, ToolOutput } from "./tool.js"; +import type { ToolApprovalDecision } from "./tool-approval.js"; import { ToolGroup } from "./tool-group.js"; type Mounted = { @@ -113,6 +114,25 @@ describe("Tool", () => { mounted.unmount(); }); + test("a failed call that then succeeds stays open, keeping focus in its output", () => { + const mounted = mount( + , + ); + const box = mounted.container.querySelector( + "[data-slot='tool-output'] [tabindex='0']", + ); + if (box === null) throw new Error("expected the output box"); + act(() => box.focus()); + mounted.render(); + expect(header(mounted.container).getAttribute("aria-expanded")).toBe( + "true", + ); + expect(box.isConnected).toBe(true); + expect(box.textContent).toBe("Renewed"); + expect(document.activeElement === box).toBe(true); + mounted.unmount(); + }); + test("a finished call starts closed and opens onto input and output", () => { const mounted = mount(); expect( @@ -357,4 +377,177 @@ describe("ToolGroup", () => { expect(decisions).toEqual(["b:approved"]); mounted.unmount(); }); + + describe("after a decision", () => { + function waitingCall(id: string): PartTool { + return tool({ + toolCallId: id, + name: `mail__${id}`, + status: "approval-requested", + approval: { + id: `approval-${id}`, + status: "pending", + scope: null, + timeoutAt: null, + }, + }); + } + + /** Records each decision on the call, as a host does. */ + function Host({ + initial, + defaultOpen, + }: { + readonly initial: PartToolBatch; + readonly defaultOpen?: boolean; + }) { + const [part, setPart] = useState(initial); + const decide = (decided: PartTool, decision: ToolApprovalDecision) => + setPart((current) => ({ + ...current, + tools: current.tools.map((call) => + call.toolCallId === decided.toolCallId + ? { + ...call, + status: + decision.outcome === "approved" + ? "running" + : "output-denied", + approval: { + id: `approval-${call.toolCallId}`, + status: decision.outcome, + scope: decision.scope, + timeoutAt: null, + }, + } + : call, + ), + })); + return ( + + ); + } + + function approveIn( + container: HTMLElement, + name: string, + ): HTMLButtonElement { + const card = [ + ...container.querySelectorAll("[data-slot='tool-approval']"), + ].find((element) => element.textContent?.includes(name)); + const found = [...(card?.querySelectorAll("button") ?? [])].find( + (element) => element.textContent === "Approve", + ); + if (found === undefined) throw new Error(`expected Approve on ${name}`); + return found; + } + + function decideByKeyboard(approve: HTMLButtonElement) { + act(() => approve.focus()); + expect(document.activeElement).toBe(approve); + act(() => approve.click()); + } + + function resolvedLine(container: HTMLElement, name: string): Element { + const line = [ + ...container.querySelectorAll("[data-slot='tool-approval']"), + ].find( + (element) => + element.getAttribute("data-status") !== "pending" && + element.textContent?.includes(name), + ); + if (line === undefined) throw new Error(`expected ${name}'s outcome`); + return line; + } + + test("deciding the last waiting call keeps the group open and focus on its outcome", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__a")); + expect( + mounted.container + .querySelector("[data-slot='tool-group-header']") + ?.getAttribute("aria-expanded"), + ).toBe("true"); + expect(mounted.container.textContent).toContain("Approved once"); + expect(document.activeElement).toBe( + resolvedLine(mounted.container, "mail__a"), + ); + mounted.unmount(); + }); + + test("with another call waiting, focus moves to that call's first button", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__a")); + expect(mounted.container.textContent).toContain("Approved once"); + expect(document.activeElement).toBe( + approveIn(mounted.container, "mail__c"), + ); + mounted.unmount(); + }); + + test("the next waiting call is the one after the decided call", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__b")); + expect(document.activeElement).toBe( + approveIn(mounted.container, "mail__c"), + ); + mounted.unmount(); + }); + + test("in a group a person opened, focus does not fall to the page", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__a")); + expect(mounted.container.textContent).toContain("Approved once"); + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement).toBe( + resolvedLine(mounted.container, "mail__a"), + ); + mounted.unmount(); + }); + + test("a person can still close the group once nothing waits", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__a")); + const groupHeader = mounted.container.querySelector( + "[data-slot='tool-group-header']", + ); + click(groupHeader); + expect(groupHeader?.getAttribute("aria-expanded")).toBe("false"); + mounted.unmount(); + }); + + test("focus elsewhere is left alone", () => { + const outside = document.createElement("input"); + document.body.appendChild(outside); + const mounted = mount( + , + ); + const approve = approveIn(mounted.container, "mail__a"); + act(() => approve.focus()); + act(() => outside.focus()); + act(() => approve.click()); + expect(document.activeElement).toBe(outside); + mounted.unmount(); + outside.remove(); + }); + }); }); diff --git a/src/ui/tool.tsx b/src/ui/tool.tsx index a210d61d..127e6fc2 100644 --- a/src/ui/tool.tsx +++ b/src/ui/tool.tsx @@ -9,7 +9,7 @@ import { LoaderCircle, ShieldAlert, } from "lucide-react"; -import { createContext, useContext, useId } from "react"; +import { createContext, useContext, useId, useState } from "react"; import type { ComponentProps, ReactNode } from "react"; import { useControllableState } from "../hooks/use-controllable-state.js"; @@ -42,9 +42,9 @@ export type ToolProps = Omit, "part"> & { /** Controlled open state. Pair with `onOpenChange`. */ readonly open?: boolean | undefined; /** - * Seeds the open state. Left out, the call is open exactly while it shows - * `error`, including when it fails after it mounted, until a person opens - * or closes it. + * Seeds the open state. Left out, the call opens when it shows `error`, + * including when it fails after it mounted, and stays open, also through + * a retry that succeeds, until a person closes it. */ readonly defaultOpen?: boolean | undefined; readonly onOpenChange?: ((open: boolean) => void) | undefined; @@ -80,7 +80,13 @@ export function Tool({ }, name: "Tool", }); - const open = chosen ?? status === "error"; + // Once a failure opened the call it stays open, so a retry that succeeds + // does not unmount the output focus may be in. Set while rendering + // (react.dev, "Storing information from previous renders"). + const failed = status === "error"; + const [autoOpened, setAutoOpened] = useState(failed); + if (failed && !autoOpened) setAutoOpened(true); + const open = chosen ?? autoOpened; const hasDetail = part.input !== undefined || part.output !== undefined; const settled = part.approval !== undefined && !isAwaitingApproval(part); From cda88012da13ab04ba309d94cc5696bc4f238f5c Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 14:45:40 +0545 Subject: [PATCH 03/10] fix(agent): keep a stray Enter from deciding an approval Approve, Approve always and Deny did not set a type, so inside a form they were submit buttons: Enter in a text field activated Approve, and aria-disabled did not stop it. They are now type="button". After a decision in a group, focus moved to the next waiting call's Approve button, so the repeat of a held Enter approved a call the person never chose. Focus now moves to that call's card, which is named by the question and the tool ("Needs your approval calendar__invite") and does nothing on Enter. The e2e scenario holds Enter across the decision to pin it. --- e2e/tool-approval.spec.ts | 12 ++++++++++-- src/ui/tool-approval.test.tsx | 32 +++++++++++++++++++++++++++++++- src/ui/tool-approval.tsx | 13 +++++++++++-- src/ui/tool-group.tsx | 15 ++++++++++----- src/ui/tool.test.tsx | 20 +++++++++++++------- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/e2e/tool-approval.spec.ts b/e2e/tool-approval.spec.ts index 7ec2806c..239f02ff 100644 --- a/e2e/tool-approval.spec.ts +++ b/e2e/tool-approval.spec.ts @@ -74,15 +74,23 @@ test("in a group, keyboard decisions keep focus inside it", async ({ () => (window as Window & { bodyFrames?: number }).bodyFrames, ); - await page.keyboard.press("Enter"); + // A held Enter: the first press decides, the repeat lands on what has focus. + await page.keyboard.down("Enter"); await expect(page.getByRole("status", { name: "Events" })).toHaveText( "mail__send:approved:once", ); await expect(waiting).toHaveCount(1); await expect( - waiting.getByRole("button", { name: "Approve", exact: true }), + page.getByRole("region", { name: "Needs your approval calendar__invite" }), ).toBeFocused(); + await page.keyboard.down("Enter"); + await page.keyboard.up("Enter"); + await expect(waiting).toHaveCount(1); + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + "mail__send:approved:once", + ); + await waiting.getByRole("button", { name: "Approve", exact: true }).focus(); await page.keyboard.press("Enter"); await expect(waiting).toHaveCount(0); const header = page.locator("[data-slot='tool-group-header']"); diff --git a/src/ui/tool-approval.test.tsx b/src/ui/tool-approval.test.tsx index 0dada256..cb979fd0 100644 --- a/src/ui/tool-approval.test.tsx +++ b/src/ui/tool-approval.test.tsx @@ -94,7 +94,13 @@ describe("ToolApproval", () => { ); const section = mounted.container.querySelector("section"); expect(section?.className).toContain("bg-attention-wash"); - expect(section?.getAttribute("aria-labelledby")).not.toBeNull(); + // Named by the question and the call, so two waiting cards differ. + const name = section + ?.getAttribute("aria-labelledby") + ?.split(" ") + .map((id) => document.getElementById(id)?.textContent) + .join(" "); + expect(name).toBe("Needs your approval mail__send"); expect(mounted.container.textContent).toContain("Needs your approval"); expect(mounted.container.textContent).toContain("mail__send"); expect(mounted.container.textContent).toContain('"to": "team"'); @@ -121,6 +127,30 @@ describe("ToolApproval", () => { mounted.unmount(); }); + test("inside a form, the buttons decide without submitting it", () => { + const decisions: ToolApprovalDecision[] = []; + let submits = 0; + const mounted = mount( +
{ + event.preventDefault(); + submits += 1; + }} + > + + decisions.push(d)} /> + , + ); + for (const [name] of cases) { + const element = button(mounted.container, name); + expect(element.type).toBe("button"); + act(() => element.click()); + } + expect(submits).toBe(0); + expect(decisions.length).toBe(3); + mounted.unmount(); + }); + test("an error is announced", () => { const mounted = mount( {awaiting ? (
@@ -176,7 +179,10 @@ export function ToolApproval({

{labels.title}

- + {part.name}
@@ -205,6 +211,7 @@ export function ToolApproval({ )}
) : null} - {steps.slice(hidden).map((step, offset) => { - const index = hidden + offset; - return ( -
  • - {renderStep(step, index)} -
  • - ); - })} + {steps.slice(hidden).map(({ step, index }) => ( +
  • + {renderStep(step, index)} +
  • + ))} ); } From 3b3dc4b3131f3ca6b41b4e6ccb36ff365f5feb51 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:37:40 +0545 Subject: [PATCH 09/10] fix(stories): show a denied call in BatchWaiting as denied The story set the call to output-available after either decision, so after Deny the row showed a check and "Done" above a "Denied" line. It now sets output-denied for a rejection, as a host should. --- stories/chat/tool.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stories/chat/tool.stories.tsx b/stories/chat/tool.stories.tsx index 1ab44ddd..0f55bb95 100644 --- a/stories/chat/tool.stories.tsx +++ b/stories/chat/tool.stories.tsx @@ -270,7 +270,9 @@ export const BatchWaiting = () => { status: approval.status === "pending" ? "approval-requested" - : "output-available", + : approval.status === "approved" + ? "output-available" + : "output-denied", input: { path: "notes/renewals.md" }, approval, }, From 716781fff8909135a89aad3b6a456795a171c1f1 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:39:21 +0545 Subject: [PATCH 10/10] fix(agent): forward refs on ToolApproval and ToolGroup under React 18 Both components read `ref` from their props. React 18 strips `ref` from the props of a plain function component, so a host's ref never reached the root there, though the peer range accepts React 18. Both are now wrapped in forwardRef, which passes the ref on React 18 and 19 alike, and still merge it with the root ref they use for focus recovery. --- src/ui/tool-approval.test.tsx | 15 +- src/ui/tool-approval.tsx | 311 +++++++++++++-------------- src/ui/tool-group.tsx | 382 ++++++++++++++++++---------------- src/ui/tool.test.tsx | 13 +- 4 files changed, 382 insertions(+), 339 deletions(-) diff --git a/src/ui/tool-approval.test.tsx b/src/ui/tool-approval.test.tsx index cb979fd0..d69565c7 100644 --- a/src/ui/tool-approval.test.tsx +++ b/src/ui/tool-approval.test.tsx @@ -1,4 +1,4 @@ -import { act } from "react"; +import { act, createRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, test } from "bun:test"; @@ -110,6 +110,19 @@ describe("ToolApproval", () => { mounted.unmount(); }); + test("a ref reaches the root", () => { + const ref = createRef(); + const mounted = mount( + {}} />, + ); + expect(ref.current).toBe( + mounted.container.querySelector( + "[data-slot='tool-approval']", + ), + ); + mounted.unmount(); + }); + test("disabled holds decisions but keeps the buttons focusable", () => { const decisions: ToolApprovalDecision[] = []; const mounted = mount( diff --git a/src/ui/tool-approval.tsx b/src/ui/tool-approval.tsx index feae3c1d..2904f0b5 100644 --- a/src/ui/tool-approval.tsx +++ b/src/ui/tool-approval.tsx @@ -9,7 +9,7 @@ import { TimerOff, X, } from "lucide-react"; -import { useCallback, useId, useLayoutEffect, useRef } from "react"; +import { forwardRef, useCallback, useId, useLayoutEffect, useRef } from "react"; import type { ComponentProps, ReactNode } from "react"; import { isAwaitingApproval } from "../lib/conversation-activity.js"; @@ -91,167 +91,172 @@ export type ToolApprovalProps = Omit< * When the record settles while focus was inside and nothing else has taken * it, focus moves to the resolved line instead of dropping to the page. */ -export function ToolApproval({ - part, - onDecide, - renderInput, - disabled = false, - error, - labels: labelOverrides, - children, - ref, - className, - onFocus, - onBlur, - ...props -}: ToolApprovalProps) { - const labels = { ...LABELS, ...labelOverrides }; - const titleId = useId(); - const nameId = useId(); - const inputTitleId = useId(); - const outcomeId = useId(); - const awaiting = isAwaitingApproval(part); - const rootRef = useRef(null); - const focusInside = useRef(false); - const wasAwaiting = useRef(awaiting); +export const ToolApproval = forwardRef( + function ToolApproval( + { + part, + onDecide, + renderInput, + disabled = false, + error, + labels: labelOverrides, + children, + className, + onFocus, + onBlur, + ...props + }, + ref, + ) { + const labels = { ...LABELS, ...labelOverrides }; + const titleId = useId(); + const nameId = useId(); + const inputTitleId = useId(); + const outcomeId = useId(); + const awaiting = isAwaitingApproval(part); + const rootRef = useRef(null); + const focusInside = useRef(false); + const wasAwaiting = useRef(awaiting); - useLayoutEffect(() => { - if (wasAwaiting.current && !awaiting && focusInside.current) { - const active = document.activeElement; - if (active === null || active === document.body) rootRef.current?.focus(); - } - wasAwaiting.current = awaiting; - }, [awaiting]); + useLayoutEffect(() => { + if (wasAwaiting.current && !awaiting && focusInside.current) { + const active = document.activeElement; + if (active === null || active === document.body) + rootRef.current?.focus(); + } + wasAwaiting.current = awaiting; + }, [awaiting]); - const setRoot = useCallback( - (element: HTMLDivElement | null) => { - rootRef.current = element; - if (typeof ref === "function") ref(element); - else if (ref !== null && ref !== undefined) ref.current = element; - }, - [ref], - ); + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); - if (!awaiting && part.approval === undefined) return null; + if (!awaiting && part.approval === undefined) return null; - const decide = (decision: ToolApprovalDecision) => () => { - if (!disabled) onDecide(decision); - }; + const decide = (decision: ToolApprovalDecision) => () => { + if (!disabled) onDecide(decision); + }; - return ( -
    { - focusInside.current = true; - onFocus?.(event); - }} - onBlur={(event) => { - // Focus leaving the card, to another element or to nothing (a click - // on blank page), ends "inside". The decided button going away with - // the record does not reach here: React ignores blur while it - // commits, so the flag survives until the layout effect reads it. - const next = event.relatedTarget; - if (next === null || !event.currentTarget.contains(next)) - focusInside.current = false; - onBlur?.(event); - }} - className={cn("min-w-0 text-sm outline-offset-2", className)} - {...props} - > - {awaiting ? ( -
    -
    - -

    - {labels.title} -

    - - {part.name} - -
    - {children} - {part.input === undefined ? null : ( -
    -

    - {labels.input} + return ( +

    { + focusInside.current = true; + onFocus?.(event); + }} + onBlur={(event) => { + // Focus leaving the card, to another element or to nothing (a click + // on blank page), ends "inside". The decided button going away with + // the record does not reach here: React ignores blur while it + // commits, so the flag survives until the layout effect reads it. + const next = event.relatedTarget; + if (next === null || !event.currentTarget.contains(next)) + focusInside.current = false; + onBlur?.(event); + }} + className={cn("min-w-0 text-sm outline-offset-2", className)} + {...props} + > + {awaiting ? ( +
    +
    + +

    + {labels.title}

    -
    - {renderInput === undefined - ? formatToolValue(part.input) - : renderInput(part.input)} + {part.name} + +
    + {children} + {part.input === undefined ? null : ( +
    +

    + {labels.input} +

    +
    + {renderInput === undefined + ? formatToolValue(part.input) + : renderInput(part.input)} +
    + )} + {error === undefined || error === null ? null : ( +

    + {error} +

    + )} +
    + + +
    - )} - {error === undefined || error === null ? null : ( -

    - {error} -

    - )} -
    - - - -
    -
    - ) : part.approval === undefined ? null : ( - - )} -
    - ); -} +
    + ) : part.approval === undefined ? null : ( + + )} +
    + ); + }, +); function ResolvedLine({ id, diff --git a/src/ui/tool-group.tsx b/src/ui/tool-group.tsx index e74ec727..84aecd8d 100644 --- a/src/ui/tool-group.tsx +++ b/src/ui/tool-group.tsx @@ -1,7 +1,14 @@ "use client"; import { ChevronRight, Layers } from "lucide-react"; -import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; import type { ComponentProps, ReactNode } from "react"; import { useControllableState } from "../hooks/use-controllable-state.js"; @@ -68,197 +75,204 @@ export type ToolGroupProps = Omit< * button: a held Enter repeats onto whatever has focus, and would decide a * call the person never chose or fold the group. */ -export function ToolGroup({ - part, - open: openProp, - defaultOpen, - onOpenChange, - onDecide, - renderTool, - labels: labelOverrides, - ref, - className, - onFocus, - onBlur, - ...props -}: ToolGroupProps) { - const labels = { ...LABELS, ...labelOverrides }; - const listId = useId(); - const awaiting = part.tools.some(isAwaitingApproval); - // `null` means nobody has opened or closed it yet, so a waiting call decides. - const [chosen, setChosen] = useControllableState({ - value: openProp, - defaultValue: defaultOpen ?? null, - onChange: - onOpenChange === undefined - ? undefined - : (next) => { - if (next !== null) onOpenChange(next); - }, - name: "ToolGroup", - }); - // Once a waiting call opened the group it stays open after the decision, - // so the outcome shows and the focused card is not unmounted with the list. - // Set while rendering (react.dev, "Storing information from previous - // renders"): an effect would run after the list had already gone. - const [autoOpened, setAutoOpened] = useState(awaiting); - if (awaiting && !autoOpened) setAutoOpened(true); - const open = chosen ?? autoOpened; +export const ToolGroup = forwardRef( + function ToolGroup( + { + part, + open: openProp, + defaultOpen, + onOpenChange, + onDecide, + renderTool, + labels: labelOverrides, + className, + onFocus, + onBlur, + ...props + }, + ref, + ) { + const labels = { ...LABELS, ...labelOverrides }; + const listId = useId(); + const awaiting = part.tools.some(isAwaitingApproval); + // `null` means nobody has opened or closed it yet, so a waiting call decides. + const [chosen, setChosen] = useControllableState({ + value: openProp, + defaultValue: defaultOpen ?? null, + onChange: + onOpenChange === undefined + ? undefined + : (next) => { + if (next !== null) onOpenChange(next); + }, + name: "ToolGroup", + }); + // Once a waiting call opened the group it stays open after the decision, + // so the outcome shows and the focused card is not unmounted with the list. + // Set while rendering (react.dev, "Storing information from previous + // renders"): an effect would run after the list had already gone. + const [autoOpened, setAutoOpened] = useState(awaiting); + if (awaiting && !autoOpened) setAutoOpened(true); + const open = chosen ?? autoOpened; - const statuses = part.tools.map(displayedToolStatus); - const active = statuses.filter( - (status) => status === "pending" || status === "running", - ).length; - const waiting = statuses.filter( - (status) => status === "approval-requested", - ).length; - const failed = countFailedTools(part.tools); - const count = part.tools.length; + const statuses = part.tools.map(displayedToolStatus); + const active = statuses.filter( + (status) => status === "pending" || status === "running", + ).length; + const waiting = statuses.filter( + (status) => status === "approval-requested", + ).length; + const failed = countFailedTools(part.tools); + const count = part.tools.length; - const rootRef = useRef(null); - const focusInside = useRef(false); - // The call whose list item last held focus: the call a decision was made on. - const focusedCall = useRef(null); - const calls = part.tools.map((tool) => tool.toolCallId); - // The calls as last committed, to place a focused call the host dropped. - const wasCalls = useRef(calls); + const rootRef = useRef(null); + const focusInside = useRef(false); + // The call whose list item last held focus: the call a decision was made on. + const focusedCall = useRef(null); + const calls = part.tools.map((tool) => tool.toolCallId); + // The calls as last committed, to place a focused call the host dropped. + const wasCalls = useRef(calls); - // A decided call is rendered anew (`Tool` in place of `ToolApproval`), or - // dropped, so its own recovery never runs; the group moves focus instead. - // This runs after every update, not only when fewer calls wait: a decision - // can land as another call starts waiting. Focus on the page while it was - // inside means the focused element went away, since leaving by a click or - // Tab clears `focusInside`. A child that stayed mounted recovers first. - useLayoutEffect(() => { - const was = wasCalls.current; - wasCalls.current = calls; - if (!focusInside.current) return; - const active = document.activeElement; - if (active !== null && active !== document.body) return; - const root = rootRef.current; - if (root === null) return; - const items = [ - ...root.querySelectorAll( - "[data-slot='tool-group-list'] > li", - ), - ]; - const focused = focusedCall.current; - const at = focused === null ? -1 : calls.indexOf(focused); - // The search starts after the decided call, or, if the host dropped it, - // at the call that followed it in the last commit. - let start = at + 1; - if (focused !== null && at === -1) { - const follower = was - .slice(was.indexOf(focused) + 1) - .find((id) => calls.includes(id)); - start = follower === undefined ? calls.length : calls.indexOf(follower); - } - // The header is a button, so it is the target only with no row left. - const target = - nextWaiting(items, start) ?? - items[at]?.querySelector("[data-slot='tool-approval']") ?? - items[at] ?? - items[start] ?? - items[items.length - 1] ?? - root.querySelector("[data-slot='tool-group-header']"); - target?.focus(); - }); + // A decided call is rendered anew (`Tool` in place of `ToolApproval`), or + // dropped, so its own recovery never runs; the group moves focus instead. + // This runs after every update, not only when fewer calls wait: a decision + // can land as another call starts waiting. Focus on the page while it was + // inside means the focused element went away, since leaving by a click or + // Tab clears `focusInside`. A child that stayed mounted recovers first. + useLayoutEffect(() => { + const was = wasCalls.current; + wasCalls.current = calls; + if (!focusInside.current) return; + const active = document.activeElement; + if (active !== null && active !== document.body) return; + const root = rootRef.current; + if (root === null) return; + const items = [ + ...root.querySelectorAll( + "[data-slot='tool-group-list'] > li", + ), + ]; + const focused = focusedCall.current; + const at = focused === null ? -1 : calls.indexOf(focused); + // The search starts after the decided call, or, if the host dropped it, + // at the call that followed it in the last commit. + let start = at + 1; + if (focused !== null && at === -1) { + const follower = was + .slice(was.indexOf(focused) + 1) + .find((id) => calls.includes(id)); + start = follower === undefined ? calls.length : calls.indexOf(follower); + } + // The header is a button, so it is the target only with no row left. + const target = + nextWaiting(items, start) ?? + items[at]?.querySelector("[data-slot='tool-approval']") ?? + items[at] ?? + items[start] ?? + items[items.length - 1] ?? + root.querySelector("[data-slot='tool-group-header']"); + target?.focus(); + }); - const setRoot = useCallback( - (element: HTMLDivElement | null) => { - rootRef.current = element; - if (typeof ref === "function") ref(element); - else if (ref !== null && ref !== undefined) ref.current = element; - }, - [ref], - ); + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); - const render = - renderTool ?? - ((tool: PartTool) => - onDecide !== undefined && isAwaitingApproval(tool) ? ( - onDecide(tool, decision)} - /> - ) : ( - - )); + const render = + renderTool ?? + ((tool: PartTool) => + onDecide !== undefined && isAwaitingApproval(tool) ? ( + onDecide(tool, decision)} + /> + ) : ( + + )); - return ( -
    { - focusInside.current = true; - onFocus?.(event); - }} - onBlur={(event) => { - // Focus leaving the group, to another element or to nothing, ends - // "inside". A decided card going away does not reach here: React - // ignores blur while it commits, so the layout effect still sees it. - const next = event.relatedTarget; - if (next === null || !event.currentTarget.contains(next)) - focusInside.current = false; - onBlur?.(event); - }} - > - - {open ? ( -
      setChosen(!open)} + className="flex w-full min-w-0 items-center gap-2 rounded-md px-1 py-1 text-start transition-colors hover:bg-muted" > - {part.tools.map((tool) => ( -
    1. { - focusedCall.current = tool.toolCallId; - }} - > - {render(tool)} -
    2. - ))} -
    - ) : null} -
    - ); -} + + + {active > 0 + ? labels.running(count, count - active - waiting) + : waiting > 0 + ? labels.held(count, count - waiting) + : labels.ran(count)} + {waiting > 0 ? <> · {labels.waiting(waiting)} : null} + {failed > 0 ? ( + + {" "} + · {labels.failed(failed)} + + ) : null} + + + + {open ? ( +
      + {part.tools.map((tool) => ( +
    1. { + focusedCall.current = tool.toolCallId; + }} + > + {render(tool)} +
    2. + ))} +
    + ) : null} +
    + ); + }, +); const WAITING = "[data-slot='tool-approval'][data-status='pending']"; diff --git a/src/ui/tool.test.tsx b/src/ui/tool.test.tsx index f66bfbcd..9195dda5 100644 --- a/src/ui/tool.test.tsx +++ b/src/ui/tool.test.tsx @@ -1,4 +1,4 @@ -import { act, useState } from "react"; +import { act, createRef, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, test } from "bun:test"; @@ -286,6 +286,17 @@ describe("ToolGroup", () => { mounted.unmount(); }); + test("a ref reaches the group's root", () => { + const ref = createRef(); + const mounted = mount(); + expect(ref.current).toBe( + mounted.container.querySelector( + "[data-slot='tool-group']", + ), + ); + mounted.unmount(); + }); + test("counts what is done while calls still run", () => { const mounted = mount(