diff --git a/DESIGN.md b/DESIGN.md index 0dbb6d538..bfef277f8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -221,38 +221,72 @@ screens. One action, one verb, everywhere that action appears. ## Tool Activity in the Conversation What an agent did between question and answer renders as sentences, never -as the material it was made from. A tool call is described by what it -accomplished — "Searched the web for 'pricing'", "Wrote a file — -report.md", "Posted a message in Slack #general" — never by its -identifier, its namespace, or a humanised spelling of either. A result is -plain text: the prose the tool returned, or a count when it returned a -list. Raw JSON never reaches a reader, expanded or not; the only -exception is code the user actually asked for, which is prose, not -machinery. - -Tense follows state: a call still running speaks in the present -("Searching…"), a settled one in the past ("Searched…"). The same rows -render mid-turn and in the persisted transcript, so nothing restyles -itself the moment a turn ends. +as the material it was made from. Tool activity is a **chip**, not a card, +not a full-width AI-Elements collapsible with "Parameters" / "Result" +headers, and not a JSON inspector. Live strip and timeline share this +chip. 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. +under the prose, one per call. Consecutive chips stack; they never fold +into "Used 3 tools" 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. 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. + +**Phrase.** A sentence in tense, derived from the tool's _end name_ — +the segment after the last `:` in an Interchange qualified id, or after +`__` in an MCP id — plus a few argument clauses (`for "…"`, `in #42`). +Present while running or pending ("Searching memory"), past when done +("Searched memory"). The same chips render mid-turn and in the persisted +transcript, so nothing restyles itself the moment a turn ends. The +qualified package path (`@scope/package/export`) never appears, even in +expanded detail — never dump `@scope/package/export:tool` or title-case +that path into a phrase. + +**Leading tile.** Known provider brands only — GitHub, GitLab, Linear, +Notion, Postgres, Slack — get a brand-colored tile. Unknown leftovers +(`memory`, `ad`, `ask-user`, `corbits`) are not brands. Local / first-party +tools use an **action glyph** (search, list, ask, memory, agents, write, +or generic lightning) on a quiet muted square — never a fake brand tile, +never an em-dash, never a minus-in-a-dark-box. + +**Status glyph.** Check when done, a spinning CircleNotch while running +or pending, WarningCircle when failed. Not a gray 6px dot. The sentence's +tense already names the state; the glyph agrees. A failure also tints the +phrase destructive. + +**Disclosure.** A caret exists only when there is human-readable detail. +A chip with nothing to disclose offers no control at all. Expanded detail +is quiet inset prose under that chip. Raw JSON, JSON strings, and +model-facing instructions (e.g. ask_user's "do not repeat this in prose") +never reach a reader — parse JSON into a count ("3 results.") or omit the +disclosure. The only exception is code the user actually asked for, which +is prose, not machinery. + +**Chrome.** Hit area ≥40px via an invisible `::before`. Radius +`--radius`. Motion via `--duration-standard` / `--ease-out`. Scale-on-press +~0.97 on the trigger. `prefers-reduced-motion` kills the spinner. + +Do: + +- End-name sentences with argument clauses; tense matches state. +- Known-provider brand tiles; action glyphs on muted squares for local + tools. +- Status Check / spinning CircleNotch / WarningCircle. +- Quiet inset prose for detail; result counts when the payload is a list. + +Don't: + +- Dump `@scope/package/export:tool` or title-case it into a phrase. +- Invent a brand from an unknown path segment. +- Show a minus, dash, or empty tile as "the provider". +- Show a gray status dot. +- Render JSON, even when the tool returned a JSON string. +- Copy Vercel AI Elements' full Parameters/Result collapsible — take the + status glyph and the quiet header, not the inspector. +- Fold consecutive chips into "Used 3 tools". ## Message Alignment diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index f6e6760ce..8e33b24b8 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -307,6 +307,7 @@ export const CHAT_STRINGS = { }, turnActivityThinking: "Thinking…", turnActivityRetry: (attempt: number) => `Retrying (attempt ${attempt})…`, + toolActivityFailed: "Failed", replyTimedOutNotice: "No reply arrived — the agent may be unavailable.", resumeFailedNotice: (refId: string) => `Couldn't resume the running reply — try again. (ref ${refId})`, diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 57df5848b..83a84e24b 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2146,18 +2146,20 @@ /* 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; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + font-size: 0.8125rem; color: var(--muted-foreground); } @@ -2168,33 +2170,46 @@ } /* 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. It takes the message column so a long chip can ellipsize + against a real width instead of a shrink-wrapped max-content cycle. */ .chat-tool-activity-row { display: flex; flex-direction: column; align-items: flex-start; gap: 0.3rem; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; } .chat-tool-activity-row[data-indented="true"] { 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; + box-sizing: border-box; + width: fit-content; + min-width: 0; 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 +2240,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 +2251,107 @@ 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-status-word { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .chat-tool-activity-meta { @@ -2283,8 +2366,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 +2378,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 +2419,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..e368aedee 100644 --- a/packages/chat-ui/src/tool-activity-view.tsx +++ b/packages/chat-ui/src/tool-activity-view.tsx @@ -3,48 +3,146 @@ // 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 chipAccessibleName(row: ToolActivityRow): string { + return row.status === "failed" + ? `${CHAT_STRINGS.toolActivityFailed}. ${row.phrase}` + : row.phrase; +} + +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.status === "failed" ? ( + + {CHAT_STRINGS.toolActivityFailed}.{" "} + + ) : null} + {row.phrase} + {row.meta === undefined ? null : ( + + )} + + {row.detail === undefined ? null : ( +