From 6ba739f8bdbd59e2173d886fb4cb437cb249059e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:27:33 -0700 Subject: [PATCH 1/6] Add tests for tool-activity chip sentences and action glyphs --- packages/chat-ui/src/tool-activity.test.ts | 163 ++++++++++++++++-- .../chat-ui/test/tool-activity-view.test.tsx | 30 +++- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/packages/chat-ui/src/tool-activity.test.ts b/packages/chat-ui/src/tool-activity.test.ts index 01627d94f..5defb56c4 100644 --- a/packages/chat-ui/src/tool-activity.test.ts +++ b/packages/chat-ui/src/tool-activity.test.ts @@ -31,13 +31,18 @@ describe("resolveToolIdentity", () => { server: "notion", tool: "search_pages", }), - ).toEqual({ provider: "notion", words: ["search", "pages"] }); + ).toEqual({ + provider: "notion", + words: ["search", "pages"], + toolName: "search_pages", + }); }); test("splits a provider-namespaced tool on its double underscore", () => { expect(resolveToolIdentity("slack__post_message", {})).toEqual({ provider: "slack", words: ["post", "message"], + toolName: "post_message", }); }); @@ -45,6 +50,7 @@ describe("resolveToolIdentity", () => { expect(resolveToolIdentity("linear.save_issue", {})).toEqual({ provider: "linear", words: ["save", "issue"], + toolName: "save_issue", }); }); @@ -52,6 +58,7 @@ describe("resolveToolIdentity", () => { expect(resolveToolIdentity("webSearch", {})).toEqual({ provider: undefined, words: ["web", "search"], + toolName: "webSearch", }); }); @@ -59,6 +66,7 @@ describe("resolveToolIdentity", () => { expect(resolveToolIdentity("mcp_read", { server: "notion" })).toEqual({ provider: undefined, words: ["mcp", "read"], + toolName: "mcp_read", }); }); }); @@ -116,13 +124,76 @@ describe("describeToolCall", () => { test("an unknown verb still loses its underscores rather than leaking raw", () => { const phrase = describeToolCall("acme__frobnicate_widget", {}, "past"); - expect(phrase).toBe("Frobnicate widget in Acme"); + expect(phrase).toBe("Frobnicate widget"); expect(phrase).not.toContain("_"); }); test("a plural object drops the article", () => { expect(describeToolCall("list_files", {}, "past")).toBe("Listed files"); }); + + test("a colon-namespaced GitHub tool still names the provider, not the id", () => { + expect(describeToolCall("github:get_issue", {}, "past")).toBe( + "Retrieved an issue in GitHub", + ); + expect(describeToolCall("github__get_issue", {}, "past")).toBe( + "Retrieved an issue in GitHub", + ); + }); + + test("a bare search names the query and never the identifier", () => { + expect(describeToolCall("search", { q: "x" }, "past")).toBe( + 'Searched for "x"', + ); + }); + + test("an unknown colon-namespaced verb title-cases the end name, not the package", () => { + expect(describeToolCall("acme:frobnicate_widget", {}, "past")).toBe( + "Frobnicate widget", + ); + }); + + test("an Interchange memory search is a layman sentence, not a qualified id", () => { + const phrase = describeToolCall( + "@corbits/memory-tools/memory:memory_search", + { query: "outbound" }, + "past", + ); + expect(phrase).toBe('Searched memory for "outbound"'); + expect(phrase).not.toContain("@"); + expect(phrase).not.toContain("/"); + expect(phrase).not.toContain(":"); + }); + + test("an Interchange memory search still running has no query to name", () => { + expect( + describeToolCall( + "@corbits/memory-tools/memory:memory_search", + {}, + "present", + ), + ).toBe("Searching memory"); + }); + + test("an Interchange list-agents call pluralizes without a package path", () => { + expect( + describeToolCall( + "@corbits/agent-directory-tools/ad:list_agents", + {}, + "past", + ), + ).toBe("Listed agents"); + }); + + test("an Interchange ask-user call is a question, not the tool id", () => { + expect( + describeToolCall( + "@corbits/interaction-tools/ask-user:ask_user", + {}, + "past", + ), + ).toBe("Asked a question"); + }); }); describe("plainTextOfOutput", () => { @@ -200,6 +271,82 @@ describe("toToolActivityRow", () => { expect(row.phrase).toBe('Searching the web for "x"'); expect(row.detail).toBeUndefined(); }); + + test("an Interchange memory search carries the end name, no provider, and a search glyph", () => { + const row = toToolActivityRow( + trace({ + name: "@corbits/memory-tools/memory:memory_search", + input: { query: "outbound" }, + status: "success", + }), + "k", + ); + expect(row.phrase).toBe('Searched memory for "outbound"'); + expect(row.provider).toBeUndefined(); + expect(row.toolName).toBe("memory_search"); + expect(["search", "memory"]).toContain(row.glyph); + expect(row.phrase).not.toContain("@"); + expect(row.phrase).not.toContain("/"); + expect(row.phrase).not.toContain(":"); + }); + + test("ask_user success never opens onto the model-facing instruction", () => { + const row = toToolActivityRow( + trace({ + name: "@corbits/interaction-tools/ask-user:ask_user", + status: "success", + output: + "The question has been shown to the user. Do not repeat the question.", + }), + "k", + ); + expect(row.phrase).toBe("Asked a question"); + expect(row.detail).toBeUndefined(); + expect(row.toolName).toBe("ask_user"); + expect(row.glyph).toBe("ask"); + }); + + test("a JSON items payload becomes a result count, not the JSON", () => { + const json = JSON.stringify({ items: [{}, {}, {}] }); + const row = toToolActivityRow( + trace({ + name: "memory_search", + status: "success", + output: json, + }), + "k", + ); + expect(row.detail).toBe("3 results."); + expect(row.detail).not.toContain("{"); + expect(row.detail).not.toContain("items"); + }); + + test("JSON sitting inside a content-block text field is still a count, not the JSON", () => { + const row = toToolActivityRow( + trace({ + name: "memory_search", + status: "success", + output: [ + { + type: "text", + text: JSON.stringify({ items: [{}, {}] }), + }, + ], + }), + "k", + ); + expect(row.detail).toBe("2 results."); + expect(row.detail).not.toContain("{"); + }); + + test("an unknown colon leftover is not a provider", () => { + const row = toToolActivityRow( + trace({ name: "acme:frobnicate_widget", status: "success" }), + "k", + ); + expect(row.provider).toBeUndefined(); + expect(row.phrase).toBe("Frobnicate widget"); + }); }); describe("providerTile", () => { @@ -214,15 +361,9 @@ describe("providerTile", () => { }); }); - 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 bare local tool with no provider namespace still gets a tile", () => { - const tile = providerTile(undefined); - expect(tile.initials.length).toBeGreaterThan(0); + test("an unrecognized leftover is not a brand tile", () => { + expect(providerTile("acme")).toBeUndefined(); + expect(providerTile("memory")).toBeUndefined(); }); }); diff --git a/packages/chat-ui/test/tool-activity-view.test.tsx b/packages/chat-ui/test/tool-activity-view.test.tsx index 8230b6c5b..318a8e591 100644 --- a/packages/chat-ui/test/tool-activity-view.test.tsx +++ b/packages/chat-ui/test/tool-activity-view.test.tsx @@ -142,7 +142,7 @@ describe("ToolActivityGroup", () => { expect(el.textContent).toContain("Repository not found"); }); - test("every chip carries a provider tile", () => { + test("a known-provider chip uses brand initials, not a dash", () => { const el = mount([ trace({ name: "slack__post_message", input: { channel: "general" } }), ]); @@ -150,4 +150,32 @@ describe("ToolActivityGroup", () => { "Sl", ); }); + + test("a qualified memory search chip is a layman sentence, not a package path", () => { + const el = mount([ + trace({ + name: "@corbits/memory-tools/memory:memory_search", + input: { query: "outbound" }, + }), + ]); + expect(el.textContent).toContain("Searched memory"); + expect(el.textContent).not.toContain("@corbits"); + expect(el.textContent).not.toContain("memory-tools"); + expect(el.innerHTML).not.toContain("—"); + expect(el.querySelector('[data-status="success"]')).not.toBeNull(); + expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull(); + }); + + test("a local tool still running keeps a status of running and a leading glyph", () => { + const el = mount([ + trace({ + name: "memory_search", + input: {}, + status: "running", + }), + ]); + expect(el.querySelector('[data-status="running"]')).not.toBeNull(); + expect(el.innerHTML).not.toContain("—"); + expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull(); + }); }); From ee2f77f4f23dd15117161bb4979729136832fe65 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:27:36 -0700 Subject: [PATCH 2/6] Restyle tool-activity chips as layman sentences with action glyphs --- packages/chat-ui/src/styles.css | 135 +++++-- packages/chat-ui/src/tool-activity-view.tsx | 147 +++++-- packages/chat-ui/src/tool-activity.ts | 402 +++++++++++++++----- packages/chat-ui/src/turn-activity.tsx | 7 +- 4 files changed, 516 insertions(+), 175 deletions(-) diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 57df5848b..0b34902d7 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2146,18 +2146,16 @@ /* 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` is the stack; each `.chat-tool-activity-row` wraps + one chip plus optional detail. The chip hugs its content so a wall of + calls reads as short tags, not a list. Radius follows `--radius`. */ .chat-tool-activity { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; padding: 0.15rem 0; - font-size: 0.72rem; + font-size: 0.8125rem; color: var(--muted-foreground); } @@ -2168,9 +2166,7 @@ } /* 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. */ + underneath. `.chat-tool-activity-chip` is what's `width:max-content`. */ .chat-tool-activity-row { display: flex; flex-direction: column; @@ -2182,19 +2178,23 @@ margin-left: 0.18rem; } -.chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)), -.chat-tool-activity-trigger { +.chat-tool-activity-chip { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; width: max-content; max-width: 100%; - flex-wrap: wrap; - border: 1px solid var(--border); - background: var(--card, var(--background)); + flex-wrap: nowrap; + border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); + background: color-mix(in srgb, var(--muted) 72%, transparent); border-radius: var(--radius); - padding: 0.4rem 0.65rem; + padding: 0.35rem 0.75rem 0.35rem 0.4rem; + transition: + transform var(--duration-standard, 180ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + background-color var(--duration-standard, 180ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + border-color var(--duration-standard, 180ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + color var(--duration-standard, 180ms) var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); } .chat-tool-activity-trigger { @@ -2225,6 +2225,10 @@ outline-offset: 2px; } +.chat-tool-activity-trigger:active:not(:disabled) { + transform: scale(0.97); +} + .chat-tool-activity-tile { display: inline-flex; flex: none; @@ -2232,43 +2236,95 @@ justify-content: center; width: 22px; height: 22px; - border-radius: 6px; + border-radius: var(--radius); font-size: 10px; font-weight: 800; color: #fff; + box-shadow: inset 0 0 0 1px color-mix(in srgb, white 22%, transparent); +} + +.chat-tool-activity-glyph { + background: color-mix(in srgb, var(--muted) 88%, var(--foreground) 6%); + color: var(--muted-foreground); + font-size: 13px; + font-weight: 400; + box-shadow: none; +} + +.chat-tool-activity-glyph svg { + width: 1em; + height: 1em; } .chat-tool-activity-marker { - width: 0.4rem; - height: 0.4rem; + display: inline-flex; flex: none; - border-radius: 99px; - background: var(--muted-foreground); - opacity: 0.6; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + color: var(--muted-foreground); +} + +.chat-tool-activity-marker svg { + width: 12px; + height: 12px; } .chat-tool-activity-marker[data-status="running"], .chat-tool-activity-marker[data-status="pending"] { - background: var(--muted-foreground); - opacity: 1; - animation: chat-block-pulse 1.6s ease infinite; + animation: chat-tool-activity-spin 0.8s linear infinite; } -/* A failure is the one state that earns colour here — everything else in - this strip is deliberately quiet chrome. */ .chat-tool-activity-marker[data-status="failed"] { - background: var(--destructive, #b42318); - opacity: 1; + color: var(--destructive, #b42318); +} + +@keyframes chat-tool-activity-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-tool-activity-chip, + .chat-tool-activity-trigger, + .chat-tool-activity-caret { + transition: none; + } + + .chat-tool-activity-trigger:active:not(:disabled) { + transform: none; + } + + .chat-tool-activity-marker[data-status="running"], + .chat-tool-activity-marker[data-status="pending"] { + animation: none; + } } .chat-tool-activity-row[data-status="failed"] .chat-tool-activity-phrase { color: var(--destructive, #b42318); + opacity: 1; +} + +.chat-tool-activity-row[data-status="running"] .chat-tool-activity-phrase, +.chat-tool-activity-row[data-status="pending"] .chat-tool-activity-phrase { + color: var(--foreground); + opacity: 0.7; } .chat-tool-activity-phrase { min-width: 0; flex: 1 1 auto; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8125rem; + font-weight: 450; + letter-spacing: -0.01em; + color: var(--foreground); + opacity: 0.82; } .chat-tool-activity-meta { @@ -2283,8 +2339,10 @@ flex: none; width: 1em; height: 1em; - font-size: 0.75rem; - transition: transform 0.15s ease-out; + font-size: 11px; + color: var(--muted-foreground); + transition: transform var(--duration-standard, 180ms) + var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); } .chat-tool-activity-caret[data-open="true"] { @@ -2293,15 +2351,21 @@ .chat-tool-activity-detail { margin: 0; - padding: 0.5rem 0.65rem; - border: 1px solid var(--border); - border-radius: var(--radius); + padding: 0.1rem 0.75rem 0.15rem 2.15rem; max-width: 100%; max-height: 12rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted-foreground); + font-size: 0.8125rem; +} + +.chat-tool-activity-thinking, +.chat-tool-activity-retry { + padding: 0.15rem 0.15rem 0.15rem 2.15rem; + font-size: 0.8125rem; + color: var(--muted-foreground); } .chat-tool-activity-thinking { @@ -2328,8 +2392,7 @@ } } - .chat-tool-activity-row:not(:has(.chat-tool-activity-trigger)), - .chat-tool-activity-trigger { + .chat-tool-activity-chip { animation: chat-tool-activity-in 160ms var(--chat-ease); } } diff --git a/packages/chat-ui/src/tool-activity-view.tsx b/packages/chat-ui/src/tool-activity-view.tsx index 4facf251b..3e55af253 100644 --- a/packages/chat-ui/src/tool-activity-view.tsx +++ b/packages/chat-ui/src/tool-activity-view.tsx @@ -3,48 +3,133 @@ // 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. 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. +// collapsibles: a glyph, one sentence, a status icon, 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 { + BookBookmark, + CaretRight, + ChatCircleDots, + Check, + CircleNotch, + Lightning, + ListBullets, + MagnifyingGlass, + PencilSimple, + Users, + WarningCircle, +} from "@corbits/icons"; +import type { ReactNode } from "react"; import { useState } from "react"; import { CHAT_STRINGS } from "./strings"; import { providerTile, + type ToolActivityGlyph, type ToolActivityRow, type ToolActivityStatus, } from "./tool-activity"; function StatusMarker({ status }: { readonly status: ToolActivityStatus }) { + const icon = + status === "failed" ? ( + + ) : status === "running" || status === "pending" ? ( + + ) : ( + + ); return ( ); } -/** 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); +function ActionGlyph({ glyph }: { readonly glyph: ToolActivityGlyph }) { + let icon: ReactNode; + switch (glyph) { + case "search": + icon = ; + break; + case "list": + icon = ; + break; + case "ask": + icon = ; + break; + case "memory": + icon = ; + break; + case "agents": + icon = ; + break; + case "write": + icon = ; + break; + default: + icon = ; + break; + } return ( ); } +function LeadingMark({ row }: { readonly row: ToolActivityRow }) { + const tile = + row.provider === undefined ? undefined : providerTile(row.provider); + if (tile !== undefined) { + return ( + + ); + } + return ; +} + +function ChipBody({ + row, + open, +}: { + readonly row: ToolActivityRow; + readonly open: boolean; +}) { + return ( + <> + + {row.phrase} + {row.meta === undefined ? null : ( + {row.meta} + )} + + {row.detail === undefined ? null : ( +