From 574c8b751bbf6b2970751161ba4ff35552071eba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:43:06 -0700 Subject: [PATCH 1/3] Add tests for spec-conforming tool-use chips Tool calls should stack as one chip per call and never fold into a "3 steps" count that hides which call actually happened (CL-6466). Replaces the round-folding assertions with ones that check every call renders on its own, and adds coverage for the new provider tile. --- packages/chat-ui/src/tool-activity.test.ts | 59 ++++++------------- .../chat-ui/test/tool-activity-view.test.tsx | 28 ++++++--- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/packages/chat-ui/src/tool-activity.test.ts b/packages/chat-ui/src/tool-activity.test.ts index f9aef125a..01627d94f 100644 --- a/packages/chat-ui/src/tool-activity.test.ts +++ b/packages/chat-ui/src/tool-activity.test.ts @@ -6,9 +6,9 @@ import type { Part, ToolTracePart } from "@corbits/chat/parts"; import { describeToolCall, - describeToolRound, groupTimelineParts, plainTextOfOutput, + providerTile, resolveToolIdentity, summarizeToolOutput, toToolActivityRow, @@ -202,50 +202,27 @@ describe("toToolActivityRow", () => { }); }); -describe("describeToolRound", () => { - const settled = (status: "success" | "failed", phrase: string) => ({ - key: phrase, - toolName: "t", - phrase, - detail: undefined, - status, - }); - - test("a round still working speaks as the step that is working", () => { - const round = describeToolRound([ - settled("success", "Read a file"), - { - key: "b", - toolName: "t", - phrase: "Searching the web", - detail: undefined, - status: "running" as const, - }, - ]); - expect(round.label).toBe("Searching the web"); - expect(round.status).toBe("running"); - expect(round.opensByDefault).toBe(false); +describe("providerTile", () => { + test("a known provider gets its brand initials and color", () => { + expect(providerTile("linear")).toEqual({ + initials: "Li", + color: "#5e6ad2", + }); + expect(providerTile("github")).toEqual({ + initials: "GH", + color: "#24292f", + }); }); - test("a settled round counts its steps and stays closed", () => { - const round = describeToolRound([ - settled("success", "a"), - settled("success", "b"), - settled("success", "c"), - ]); - expect(round.label).toBe("3 steps"); - expect(round.opensByDefault).toBe(false); + test("an unrecognized provider still gets a tile, neutral and initialed", () => { + const tile = providerTile("acme"); + expect(tile.initials).toBe("Ac"); + expect(tile.color).toBe("var(--muted-foreground)"); }); - test("a round with failures says so plainly and opens itself", () => { - const round = describeToolRound([ - settled("success", "a"), - settled("failed", "b"), - settled("failed", "c"), - ]); - expect(round.label).toBe("3 steps, 2 didn't work"); - expect(round.status).toBe("failed"); - expect(round.opensByDefault).toBe(true); + test("a bare local tool with no provider namespace still gets a tile", () => { + const tile = providerTile(undefined); + expect(tile.initials.length).toBeGreaterThan(0); }); }); diff --git a/packages/chat-ui/test/tool-activity-view.test.tsx b/packages/chat-ui/test/tool-activity-view.test.tsx index 2564a4e00..8230b6c5b 100644 --- a/packages/chat-ui/test/tool-activity-view.test.tsx +++ b/packages/chat-ui/test/tool-activity-view.test.tsx @@ -102,31 +102,30 @@ describe("ToolActivityGroup", () => { ).toBe("running"); }); - test("consecutive rounds collapse to one line that opens onto the steps", () => { + test("consecutive calls stack as individual chips, never a count", () => { const el = mount([ trace({ name: "web_search", input: { query: "a" } }), trace({ name: "read_file", input: { path: "src/app.ts" } }), trace({ name: "write_file", input: { path: "src/app.ts" } }), ]); - expect(el.textContent).toContain("3 steps"); - expect(el.textContent).not.toContain("Wrote a file"); - - click(el.querySelector(".chat-tool-activity-trigger")); + expect(el.textContent).not.toContain("steps"); expect(el.textContent).toContain('Searched the web for "a"'); expect(el.textContent).toContain("Read a file — app.ts"); expect(el.textContent).toContain("Wrote a file — app.ts"); + expect(el.querySelectorAll(".chat-tool-activity-row").length).toBe(3); }); - test("a round that is still working shows the step that is working", () => { + test("a call still running renders alongside settled calls, not folded", () => { const el = mount([ trace({ name: "read_file", input: { path: "a.ts" } }), trace({ name: "web_search", input: { query: "b" }, status: "running" }), ]); expect(el.textContent).toContain('Searching the web for "b"'); + expect(el.textContent).toContain("Read a file — a.ts"); expect(el.textContent).not.toContain("2 steps"); }); - test("a round containing a failure names the failure and opens itself", () => { + test("a failed call among others names its own failure, on its own chip", () => { const el = mount([ trace({ name: "read_file", input: { path: "a.ts" } }), trace({ @@ -135,7 +134,20 @@ describe("ToolActivityGroup", () => { output: "Repository not found", }), ]); - expect(el.textContent).toContain("2 steps, 1 didn't work"); + expect(el.textContent).not.toContain("didn't work"); expect(el.textContent).toContain("Retrieved an issue in GitHub"); + const failedRow = el.querySelector('[data-status="failed"]'); + expect(failedRow).not.toBeNull(); + click(failedRow?.querySelector(".chat-tool-activity-trigger") ?? null); + expect(el.textContent).toContain("Repository not found"); + }); + + test("every chip carries a provider tile", () => { + const el = mount([ + trace({ name: "slack__post_message", input: { channel: "general" } }), + ]); + expect(el.querySelector(".chat-tool-activity-tile")?.textContent).toBe( + "Sl", + ); }); }); From b0b8e88b96843259216a769b63f56f96c2b82186 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:43:15 -0700 Subject: [PATCH 2/3] Tool-use chips: stack per call, conform to the mock spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #190 folded runs of tool calls into one collapsed line ("3 steps", opens only on failure) and documented that behavior as canon. The spec (mock-spec.md §12.3) says chips are not collapsibles: every call renders as its own chip, stacked under the prose, so a reader can see which call happened rather than a count of implementation objects that reads identically whether the calls were benign or public. - Remove describeToolRound and the round-folding UI; ToolActivityGroup now stacks every row as its own chip. - Add the missing provider tile (22x22, brand-colored, two-letter) from the anatomy in §12.3. - Add the chip's rowIn-style entrance animation, respecting prefers-reduced-motion. - Extend the disclosure trigger's hit area to 40px via a pseudo-element without growing the visible chip. - Fix token violations: chip/detail radius now uses the shared --radius token instead of a hardcoded 0, and the caret is sized by font-size rather than width/height. --- packages/chat-ui/src/styles.css | 119 ++++++++++++++------ packages/chat-ui/src/tool-activity-view.tsx | 79 ++++++------- packages/chat-ui/src/tool-activity.ts | 93 +++++++-------- packages/chat-ui/src/turn-activity.tsx | 7 +- 4 files changed, 175 insertions(+), 123 deletions(-) diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 3c5fc4c8c..af49c0627 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1955,61 +1955,78 @@ color: var(--muted-foreground); } -/* The live turn-activity strip (CL-6196): tool-call chips, a thinking - row, and a retry note, shown above the composer while a turn streams. - Square, zero-radius, hairline borders — matches `.chat-block`'s - generative-UI language rather than the rounded typing-dot pill. */ +/* Tool-use chips (CL-6466, conforming to mock-spec §12.3): inline chips + stacked under the agent's prose, one per call — never collapsibles. + `.chat-tool-activity` is the stack; each `.chat-tool-activity-row` is one + chip, `width:max-content` so a wall of calls reads as short tags, not a + list. Radius uses the shared `--radius` token (§1's 8px base), matching + every other chip in the app instead of the zero-radius the old + generative-UI framing called for. */ .chat-tool-activity { display: flex; flex-direction: column; - gap: 0.15rem; + align-items: flex-start; + gap: 0.5rem; padding: 0.15rem 0; font-size: 0.72rem; color: var(--muted-foreground); } .chat-tool-activity-live { - gap: 0.3rem; + gap: 0.4rem; padding: 0.35rem 0.7rem; border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); } +/* The row is the outer wrapper — chip on top, its optional detail + underneath. The chip itself (this selector plus `.chat-tool-activity- + trigger`) is what's `width:max-content`: it hugs its content so a wall + of calls reads as a stack of short tags, never spanning the column. */ .chat-tool-activity-row { display: flex; flex-direction: column; - gap: 0.2rem; + align-items: flex-start; + gap: 0.3rem; } .chat-tool-activity-row[data-indented="true"] { - padding-left: 0.85rem; - border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); margin-left: 0.18rem; } -/* A row with no detail is inert text, not a control: it lines up with the - triggers above and below it but never invites a click. */ -.chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)) { +.chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)), +.chat-tool-activity-trigger { + display: flex; flex-direction: row; align-items: center; - gap: 0.45rem; - padding-block: 0.15rem; + gap: 0.5rem; + width: max-content; + max-width: 100%; + flex-wrap: wrap; + border: 1px solid var(--border); + background: var(--card, var(--background)); + border-radius: var(--radius); + padding: 0.4rem 0.65rem; } .chat-tool-activity-trigger { - display: flex; - align-items: center; - gap: 0.45rem; - width: 100%; - padding: 0.15rem 0; - border: 0; - border-radius: 0; - background: none; + position: relative; color: inherit; font: inherit; text-align: left; cursor: pointer; } +/* The visible chip can stay compact; the hit area cannot — a pseudo-element + pads the trigger out to a 40px tall target without changing the chip's + footprint in the transcript. */ +.chat-tool-activity-trigger::before { + content: ""; + position: absolute; + inset: 50% 0 auto 0; + transform: translateY(-50%); + min-height: 2.5rem; +} + .chat-tool-activity-trigger:hover { color: var(--foreground); } @@ -2019,11 +2036,24 @@ outline-offset: 2px; } +.chat-tool-activity-tile { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + font-size: 10px; + font-weight: 800; + color: #fff; +} + .chat-tool-activity-marker { width: 0.4rem; height: 0.4rem; flex: none; - border-radius: 0; + border-radius: 99px; background: var(--muted-foreground); opacity: 0.6; } @@ -2058,10 +2088,13 @@ opacity: 0.75; } +/* Sized by font-size, never width/height (§1): the svg itself is 1em + square, so this class is the only place its size is set. */ .chat-tool-activity-caret { flex: none; - width: 0.75rem; - height: 0.75rem; + width: 1em; + height: 1em; + font-size: 0.75rem; transition: transform 0.15s ease-out; } @@ -2070,9 +2103,11 @@ } .chat-tool-activity-detail { - margin: 0 0 0.2rem 0.85rem; - padding-left: 0.7rem; - border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); + margin: 0; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius); + max-width: 100%; max-height: 12rem; overflow: auto; white-space: pre-wrap; @@ -2080,12 +2115,6 @@ color: var(--muted-foreground); } -.chat-tool-activity-rows { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - .chat-tool-activity-thinking { font-style: italic; } @@ -2094,6 +2123,28 @@ color: var(--warn, #b7791f); } +/* Chips animate in with the same entrance list rows use (§12.3 rule 4), + so they don't just pop into layout mid-stream. Only the chip itself + plays it — not the detail box that opens later, which has its own + moment. */ +@media (prefers-reduced-motion: no-preference) { + @keyframes chat-tool-activity-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)), + .chat-tool-activity-trigger { + animation: chat-tool-activity-in 160ms var(--chat-ease); + } +} + /* iMessage-style pulse: a small grey bubble of three bouncing dots in the incoming-message slot (same indent as a grouped reply). Who-is-typing copy is visually hidden. */ diff --git a/packages/chat-ui/src/tool-activity-view.tsx b/packages/chat-ui/src/tool-activity-view.tsx index b2e3c9c69..4facf251b 100644 --- a/packages/chat-ui/src/tool-activity-view.tsx +++ b/packages/chat-ui/src/tool-activity-view.tsx @@ -2,17 +2,19 @@ // // One presentation serves both the live strip (`turn-activity.tsx`) and the // persisted transcript (`timeline.tsx`), so a call that reads one way while -// it runs doesn't restyle itself the moment the turn ends. Rows, not cards: -// a status marker, one sentence, and — only when there is something to -// show — a disclosure onto plain-text detail. The sentences come from -// `tool-activity.ts`; nothing here formats a tool's own data. +// it runs doesn't restyle itself the moment the turn ends. Chips, not +// collapsibles: a provider tile, one sentence, a status marker, and — only +// when there is something to show — a disclosure onto plain-text detail. +// Calls stack one per call; nothing here ever folds several into a count. +// The sentences come from `tool-activity.ts`; nothing here formats a +// tool's own data. import { CaretRight } from "@corbits/icons"; import { useState } from "react"; import { CHAT_STRINGS } from "./strings"; import { - describeToolRound, + providerTile, type ToolActivityRow, type ToolActivityStatus, } from "./tool-activity"; @@ -27,6 +29,22 @@ function StatusMarker({ status }: { readonly status: ToolActivityStatus }) { ); } +/** The chip's leading brand mark — 22×22, provider-colored, two letters. + * Present on every chip, per §12.3's anatomy; a bare local tool gets the + * neutral fallback tile rather than no tile at all. */ +function ProviderTile({ provider }: { readonly provider: string | undefined }) { + const tile = providerTile(provider); + return ( + + ); +} + function ToolActivityLine({ row, indented, @@ -44,11 +62,12 @@ function ToolActivityLine({ data-status={row.status} data-indented={indented} > - + {row.phrase} {row.meta === undefined ? null : ( {row.meta} )} + ); } @@ -65,11 +84,12 @@ function ToolActivityLine({ aria-expanded={open} onClick={() => setOpen((value) => !value)} > - + {row.phrase} {row.meta === undefined ? null : ( {row.meta} )} + - - - ); - } - return ( -
- - {open ? ( -
- {rows.map((row) => ( - - ))} -
- ) : null} +
+ {rows.map((row) => ( + + ))}
); } diff --git a/packages/chat-ui/src/tool-activity.ts b/packages/chat-ui/src/tool-activity.ts index 2222732b6..e2641b4df 100644 --- a/packages/chat-ui/src/tool-activity.ts +++ b/packages/chat-ui/src/tool-activity.ts @@ -27,6 +27,9 @@ export type ToolActivityRow = { /** The raw tool identifier. Never rendered — carried so a row keeps its * provenance for tests and debugging. */ readonly toolName: string; + /** The provider namespace a call belongs to, e.g. `"slack"` — undefined + * for a bare local tool. Feeds the chip's leading tile. */ + readonly provider: string | undefined; readonly phrase: string; /** The on-demand detail, already plain text. Undefined when the tool * returned nothing legible — a row with no detail offers no disclosure @@ -96,6 +99,46 @@ const PROVIDER_NAMES: Record = { slack: "Slack", }; +export type ProviderTile = { + readonly initials: string; + readonly color: string; +}; + +/** Brand mark for the chip's leading tile — two letters and the provider's + * own color, the way the mock's `[Li #5E6AD2]` / `[GH #24292f]` read. Only + * providers a person would recognise on sight get a fixed brand color; + * anything else falls back to a neutral tile rather than guessing a color. */ +const PROVIDER_TILES: Record = { + github: { initials: "GH", color: "#24292f" }, + gitlab: { initials: "GL", color: "#fc6d26" }, + linear: { initials: "Li", color: "#5e6ad2" }, + notion: { initials: "No", color: "#000000" }, + postgres: { initials: "Pg", color: "#336791" }, + slack: { initials: "Sl", color: "#4a154b" }, +}; + +const FALLBACK_TILE_COLOR = "var(--muted-foreground)"; + +function fallbackInitials(provider: string): string { + const word = provider.replace(/[_-]+/g, "").trim(); + if (word.length >= 2) { + return `${word.charAt(0).toUpperCase()}${word.charAt(1).toLowerCase()}`; + } + return word.toUpperCase() || "?"; +} + +/** The tile a tool-use chip leads with. `undefined` (a bare local tool, + * no provider namespace) reads as a neutral "-" tile rather than nothing — + * the anatomy always has a tile. */ +export function providerTile(provider: string | undefined): ProviderTile { + if (provider === undefined) { + return { initials: "—", color: FALLBACK_TILE_COLOR }; + } + const known = PROVIDER_TILES[provider.toLowerCase()]; + if (known !== undefined) return known; + return { initials: fallbackInitials(provider), color: FALLBACK_TILE_COLOR }; +} + function asRecord(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; @@ -396,52 +439,13 @@ export function toToolActivityRow( return { key, toolName: part.name, + provider: resolveToolIdentity(part.name, part.input).provider, phrase: describeToolCall(part.name, part.input, tense), detail: summarizeToolOutput(status, part.output), status, }; } -export type ToolRoundSummary = { - readonly label: string; - readonly status: ToolActivityStatus; - /** Whether the group shows its rows without being asked. A round that - * contains a failure is one where the detail is the point. */ - readonly opensByDefault: boolean; -}; - -/** - * The one line that stands in for a whole round of tool calls. While - * something is still running the round speaks as that step ("Searching - * the web for …") — the reader wants to know what is happening now, not - * that five things happened. Once settled it counts, and says plainly if - * any of them didn't work. - */ -export function describeToolRound( - rows: readonly ToolActivityRow[], -): ToolRoundSummary { - const active = rows.find( - (row) => row.status === "running" || row.status === "pending", - ); - if (active !== undefined) { - return { - label: active.phrase, - status: active.status, - opensByDefault: false, - }; - } - const failures = rows.filter((row) => row.status === "failed").length; - const stepCount = `${rows.length} steps`; - if (failures > 0) { - return { - label: `${stepCount}, ${failures} didn't work`, - status: "failed", - opensByDefault: true, - }; - } - return { label: stepCount, status: "success", opensByDefault: false }; -} - export type TimelinePartGroup = | { readonly kind: "part"; readonly part: Part; readonly key: string } | { @@ -451,10 +455,11 @@ export type TimelinePartGroup = }; /** - * Folds a message's parts into render groups, coalescing every run of - * consecutive tool calls into one. An agent that searches, reads four - * files and edits two of them produced seven parts and one piece of news: - * grouping is what keeps the reply visible above its own machinery. + * Splits a message's parts into render groups, clustering every run of + * consecutive tool calls together. The cluster renders as a stack of + * chips, one per call — never folded into a summary line — so this is + * purely about keeping tool calls next to each other in the flow, not + * about hiding them. */ export function groupTimelineParts( parts: readonly Part[], diff --git a/packages/chat-ui/src/turn-activity.tsx b/packages/chat-ui/src/turn-activity.tsx index edcfdbcee..c2fa57228 100644 --- a/packages/chat-ui/src/turn-activity.tsx +++ b/packages/chat-ui/src/turn-activity.tsx @@ -15,7 +15,11 @@ import { useEffect, useState } from "react"; -import { describeToolCall, type ToolActivityRow } from "./tool-activity"; +import { + describeToolCall, + resolveToolIdentity, + type ToolActivityRow, +} from "./tool-activity"; import { LiveToolActivity } from "./tool-activity-view"; export type ToolCallActivity = { @@ -387,6 +391,7 @@ export function toolActivityRows( const base = { key: call.callId, toolName: call.name, + provider: resolveToolIdentity(call.name, call.input).provider, phrase: describeToolCall( call.name, call.input, From 71db9de397f55bc2ae4354f0e963718fb602fee7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:43:20 -0700 Subject: [PATCH 3/3] Update docs: tool-use chip contract matches the spec DESIGN.md documented PR #190's round-folding as canon, contradicting the mock spec. Rewrite the section to describe the actual chip contract: stacked, un-folded chips with a provider tile, not a collapsible with a step count. --- DESIGN.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 84b823376..8084076a8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -193,17 +193,24 @@ Tense follows state: a call still running speaks in the present render mid-turn and in the persisted transcript, so nothing restyles itself the moment a turn ends. -Consecutive calls fold into one round on a single line — the step -currently working while the turn is open, then a count of steps once it -settles. Rounds stay collapsed by default, including while running: the -answer is what the reader is waiting for, and a transcript that expands -its own machinery pushes the answer off screen. Detail opens on demand, -one click, and a row with nothing to show offers no disclosure at all. - -A failure says so plainly, in words, and opens itself — it is the one -state where the detail is the point. A tool that failed without saying -why still says that much. Failure is the only state in this strip that -earns colour; everything else is quiet chrome. +Tool calls render as inline chips inside the agent's message body, stacked +under the prose, one per call — never a collapsible, never a count. +Consecutive calls do not fold into a summary line or a "3 steps" total: a +count of implementation objects tells a reader nothing about what actually +happened, and hides the one call among many that might matter (a public +Slack post reads identically to three benign file reads once it's +flattened to a number). Each chip is `width:max-content` — it hugs its own +content rather than spanning the column, so a wall of calls reads as a +stack of short tags, not a wall of prose. + +A chip's anatomy, left to right: a small provider tile (brand-colored, +two-letter initials) so a reader can tell at a glance which system a call +touched, then the sentence describing what happened, then a quiet status +marker. Detail opens on demand, one click, on the individual chip that has +something to show; a chip with nothing to disclose offers no control at +all. A failure says so plainly, in words, on its own chip — it is the one +state where colour appears; everything else in this strip is quiet +chrome. ## State Pills