Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 64 additions & 30 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`,
Expand Down
164 changes: 127 additions & 37 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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 {
Expand Down Expand Up @@ -2225,50 +2240,118 @@
outline-offset: 2px;
}

.chat-tool-activity-trigger:active:not(:disabled) {
transform: scale(0.97);
}

.chat-tool-activity-tile {
display: inline-flex;
flex: none;
align-items: center;
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 {
Expand All @@ -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"] {
Expand All @@ -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 {
Expand All @@ -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);
}
}
Expand Down
Loading
Loading