diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65d35f1..0b51180 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 0000000..e8a62ec --- /dev/null +++ b/e2e/contrast-dom.ts @@ -0,0 +1,443 @@ +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); + // An inset shadow paints over the colour, so the colour is not the ground. + if ( + resolve(style.backgroundColor)[3] === 1 && + style.backgroundClip === "border-box" && + !style.boxShadow.includes("inset") && + 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 inset shadow paints over its image, and its image over + // its colour; its backdrop filter works on what is behind it; its + // opacity fades all of it. + if (style.boxShadow.includes("inset")) + layers.push({ kind: "unresolved", box }); + 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 0000000..b393e7e --- /dev/null +++ b/e2e/contrast-fixtures.spec.ts @@ -0,0 +1,171 @@ +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 }); + }); + +// The inset shadow paints #000 over the white the text would be judged on. +// Axe settles this page itself, so only the fallback's verdict is pinned. +test("the contrast fallback does not pass text on an opaque ground under an inset shadow", async ({ + page: tab, +}) => { + const { verdict } = await verdictFor( + tab, + page( + `

Pending approval

`, + ), + ); + 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 0000000..5bc8788 --- /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 0000000..d799eef --- /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 3440588..88ceef0 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -48,6 +48,13 @@ import { PanelTabs, panelTabsHeaderClassName, useTabs, + Activity, + ToolApproval, + groupActivity, + type PartTool, + type ToolApprovalRecord, + ToolGroup, + type PartToolBatch, } from "@corbits/react-ui"; import { CommandPalette, @@ -940,6 +947,128 @@ 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} + + ); +} + +// 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": () => , @@ -982,4 +1111,6 @@ export const scenarios: Record ReactNode> = { "prompt-input-toolbar": () => , "prompt-input-toolbar-dialog": () => , "panel-tabs": () => , + "tool-approval": () => , + "tool-group-approval": () => , }; diff --git a/e2e/stories-a11y.spec.ts b/e2e/stories-a11y.spec.ts index 9439c5b..533cd67 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 0000000..239f02f --- /dev/null +++ b/e2e/tool-approval.spec.ts @@ -0,0 +1,113 @@ +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", + ); +}); + +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, + ); + + // 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( + 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']"); + 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/package.json b/package.json index bc9d1e5..cfa20e3 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 e7e2eff..2402604 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 0000000..f196017 --- /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 0000000..164da3d --- /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 0000000..fd6e1c5 --- /dev/null +++ b/src/ui/activity.test.tsx @@ -0,0 +1,362 @@ +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("while streaming, only the newest thought is still thinking", () => { + const started: PartReasoning = { kind: "reasoning", text: "" }; + const mounted = mount( + , + ); + const thoughts = [ + ...mounted.container.querySelectorAll("[data-slot='reasoning']"), + ]; + // The empty live thought still shows, as thinking; the earlier one is done. + expect(thoughts.length).toBe(2); + expect(thoughts[0]?.hasAttribute("data-streaming")).toBe(false); + expect(thoughts[0]?.textContent).toContain("Thought for"); + expect(thoughts[1]?.getAttribute("data-streaming")).toBe("true"); + expect(thoughts[1]?.textContent).toContain("Thinking…"); + mounted.unmount(); + + // Once the run ends, the same thought is finished. + const done = mount(); + expect( + done.container + .querySelector("[data-slot='reasoning']") + ?.hasAttribute("data-streaming"), + ).toBe(false); + done.unmount(); + }); + + test("a finished thought with no text gets no step of its own", () => { + const blank: PartReasoning = { kind: "reasoning", text: " " }; + const steps = [blank, ...Array.from({ length: 11 }, (_, i) => call(i))]; + const mounted = mount(); + const items = [...mounted.container.querySelectorAll("li[data-step]")]; + expect(items.every((item) => item.innerHTML !== "")).toBe(true); + expect( + mounted.container.querySelector("li[data-step='reasoning']"), + ).toBeNull(); + // It does not count toward the ten shown or the ones held back. + expect(mounted.container.textContent).toContain("Show 1 earlier step"); + const more = [...mounted.container.querySelectorAll("button")].find( + (button) => button.textContent === "Show 1 earlier step", + ); + act(() => more?.click()); + expect(document.activeElement?.textContent).toContain("files__read_0"); + mounted.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 0000000..00a703e --- /dev/null +++ b/src/ui/activity.tsx @@ -0,0 +1,350 @@ +"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. A finished thought with no text has + * nothing to show and is left out. + * + * 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, and while the + * run streams a thought that is its newest step shows as still thinking. + * `index` is the step's place in the run. It is not called for a finished + * thought with no text. + */ + readonly renderStep?: + | ((step: ActivityStep, index: number) => ReactNode) + | undefined; + readonly labels?: Partial | undefined; + readonly className?: string | undefined; +}; + +type ShownStep = { readonly step: ActivityStep; readonly index: number }; + +function defaultStep(step: ActivityStep, live: boolean): 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. + * A finished thought with no text is left out, as `Reasoning` shows nothing + * for it. + */ +export function ActivityContent({ + renderStep, + labels: labelOverrides, + className, +}: ActivityContentProps) { + const { steps, streaming, open, contentId } = + useActivityContext("ActivityContent"); + if (!open) return null; + const live = (index: number) => streaming && index === steps.length - 1; + // An empty item would still be read as a list item, take focus from "Show + // earlier" and count toward the ten shown. Each keeps its index in the run. + const shown = steps.flatMap((step, index): ShownStep[] => + step.kind === "reasoning" && !live(index) && step.text.trim().length === 0 + ? [] + : [{ step, index }], + ); + return ( + defaultStep(step, live(index))) + } + labels={{ ...CONTENT_LABELS, ...labelOverrides }} + className={className} + /> + ); +} + +// 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 ShownStep[]; + 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, index }) => ( +
  3. + {renderStep(step, index)} +
  4. + ))} +
+ ); +} diff --git a/src/ui/reasoning.tsx b/src/ui/reasoning.tsx new file mode 100644 index 0000000..40e48ff --- /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 0000000..d69565c --- /dev/null +++ b/src/ui/tool-approval.test.tsx @@ -0,0 +1,335 @@ +import { act, createRef } 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"); + // 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"'); + expect(mounted.container.textContent).toContain( + "Send the renewal summary to the team.", + ); + 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( + 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("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( + {}} + />, + ); + 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("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( + "[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 0000000..2904f0b --- /dev/null +++ b/src/ui/tool-approval.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { + Ban, + Check, + CheckCheck, + Hourglass, + ShieldAlert, + TimerOff, + X, +} from "lucide-react"; +import { forwardRef, 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 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]); + + 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({ + 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}{" "} + {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 0000000..84aecd8 --- /dev/null +++ b/src/ui/tool-group.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { ChevronRight, Layers } from "lucide-react"; +import { + forwardRef, + useCallback, + useId, + useLayoutEffect, + useRef, + useState, +} 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 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; + /** 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. + * + * When a decision settles a call while focus was inside and nothing else has + * taken it, focus moves to the next waiting call's card, else to the decided + * call's outcome, else to the call's row in the list (or, if the host dropped + * it, the row now in its place), instead of dropping to the page. Never to a + * button: a held Enter repeats onto whatever has focus, and would decide a + * call the person never chose or fold the group. + */ +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 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(); + }); + + 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)} + /> + ) : ( + + )); + + 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 ? ( +
    + {part.tools.map((tool) => ( +
  1. { + focusedCall.current = tool.toolCallId; + }} + > + {render(tool)} +
  2. + ))} +
+ ) : null} +
+ ); + }, +); + +const WAITING = "[data-slot='tool-approval'][data-status='pending']"; + +/** The card of the first waiting call from `start`, else of the first. */ +function nextWaiting( + items: readonly Element[], + start: number, +): HTMLElement | null { + const waiting = (item: Element) => item.querySelector(WAITING) !== null; + const item = items.slice(start).find(waiting) ?? items.find(waiting); + return ( + item?.querySelector( + `${WAITING} [data-slot='tool-approval-card']`, + ) ?? null + ); +} diff --git a/src/ui/tool.test.tsx b/src/ui/tool.test.tsx new file mode 100644 index 0000000..9195dda --- /dev/null +++ b/src/ui/tool.test.tsx @@ -0,0 +1,660 @@ +import { act, createRef, 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 = { + 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 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( + 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("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( + , + ); + 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(); + }); + + 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 cardOf(container: HTMLElement, name: string): Element { + const card = [ + ...container.querySelectorAll("[data-slot='tool-approval-card']"), + ].find((element) => element.textContent?.includes(name)); + if (card === undefined) throw new Error(`expected ${name}'s card`); + return card; + } + + 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 card, not a button", () => { + const mounted = mount( + , + ); + decideByKeyboard(approveIn(mounted.container, "mail__a")); + expect(mounted.container.textContent).toContain("Approved once"); + // A held Enter repeats onto the focused element; on the card it decides nothing. + expect(document.activeElement).toBe(cardOf(mounted.container, "mail__c")); + expect(document.activeElement?.tagName).not.toBe("BUTTON"); + 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(cardOf(mounted.container, "mail__c")); + mounted.unmount(); + }); + + test("a decision that lands as another call starts waiting keeps focus", () => { + const decided = (call: PartTool): PartTool => ({ + ...call, + status: "running", + approval: { + id: `approval-${call.toolCallId}`, + status: "approved", + scope: "once", + timeoutAt: null, + }, + }); + const a = waitingCall("a"); + const later = tool({ + toolCallId: "b", + name: "mail__b", + status: "running", + }); + const render = (part: PartToolBatch) => ( + {}} /> + ); + const mounted = mount(render(batch([a, later]))); + act(() => approveIn(mounted.container, "mail__a").focus()); + // One update: the focused call settles and another starts waiting, so + // the number of waiting calls does not change. + mounted.render(render(batch([decided(a), waitingCall("b")]))); + expect(document.activeElement).toBe(cardOf(mounted.container, "mail__b")); + mounted.unmount(); + }); + + test("a focused call dropped from the middle hands focus to the one after it", () => { + const render = (part: PartToolBatch) => ( + {}} /> + ); + const [a, b, c] = [waitingCall("a"), waitingCall("b"), waitingCall("c")]; + const mounted = mount(render(batch([a, b, c]))); + act(() => approveIn(mounted.container, "mail__b").focus()); + mounted.render(render(batch([a, c]))); + expect(document.activeElement).toBe(cardOf(mounted.container, "mail__c")); + mounted.unmount(); + }); + + test("a dropped call is placed by the call after it, though calls shift", () => { + const render = (part: PartToolBatch) => ( + {}} /> + ); + const [a, b, c] = [waitingCall("a"), waitingCall("b"), waitingCall("c")]; + const first = tool({ toolCallId: "x", name: "files__x" }); + const mounted = mount(render(batch([a, b, c]))); + act(() => approveIn(mounted.container, "mail__b").focus()); + // b goes and a new call lands first, so b's old place now holds a. + mounted.render(render(batch([first, a, c]))); + expect(document.activeElement).toBe(cardOf(mounted.container, "mail__c")); + mounted.unmount(); + }); + + test("with no outcome to show, focus moves to the decided call's row, not a button", () => { + const render = (part: PartToolBatch) => ( + {}} /> + ); + // No approval record: the host says only that the call now runs. + const a = tool({ + toolCallId: "a", + name: "mail__a", + status: "approval-requested", + }); + const done = tool({ toolCallId: "r" }); + const mounted = mount(render(batch([done, a]))); + act(() => approveIn(mounted.container, "mail__a").focus()); + mounted.render(render(batch([done, { ...a, status: "running" }]))); + const active = document.activeElement; + // A held Enter repeats onto the focused element; on the row it folds nothing. + expect(active?.tagName).toBe("LI"); + expect(active?.textContent).toContain("mail__a"); + mounted.unmount(); + }); + + test("a dropped call with nothing left waiting hands focus to a row", () => { + const render = (part: PartToolBatch) => ( + {}} /> + ); + const done = tool({ toolCallId: "r", name: "files__r" }); + const mounted = mount(render(batch([done, waitingCall("a")]))); + act(() => approveIn(mounted.container, "mail__a").focus()); + mounted.render(render(batch([done]))); + const active = document.activeElement; + expect(active?.tagName).toBe("LI"); + expect(active?.textContent).toContain("files__r"); + 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 new file mode 100644 index 0000000..127e6fc --- /dev/null +++ b/src/ui/tool.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { + Ban, + Check, + ChevronRight, + CircleAlert, + CircleDashed, + LoaderCircle, + ShieldAlert, +} from "lucide-react"; +import { createContext, useContext, useId, useState } 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 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; +}; + +/** + * 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", + }); + // 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); + + 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 0000000..b215f22 --- /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 0000000..0f55bb9 --- /dev/null +++ b/stories/chat/tool.stories.tsx @@ -0,0 +1,291 @@ +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, + }) + } + /> + + ); +};