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
29 changes: 18 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
119 changes: 85 additions & 34 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -2070,22 +2103,18 @@
}

.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;
overflow-wrap: anywhere;
color: var(--muted-foreground);
}

.chat-tool-activity-rows {
display: flex;
flex-direction: column;
gap: 0.15rem;
}

.chat-tool-activity-thinking {
font-style: italic;
}
Expand All @@ -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. */
Expand Down
79 changes: 35 additions & 44 deletions packages/chat-ui/src/tool-activity-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<span
className="chat-tool-activity-tile"
style={{ background: tile.color }}
aria-hidden="true"
>
{tile.initials}
</span>
);
}

function ToolActivityLine({
row,
indented,
Expand All @@ -44,11 +62,12 @@ function ToolActivityLine({
data-status={row.status}
data-indented={indented}
>
<StatusMarker status={row.status} />
<ProviderTile provider={row.provider} />
<span className="chat-tool-activity-phrase">{row.phrase}</span>
{row.meta === undefined ? null : (
<span className="chat-tool-activity-meta">{row.meta}</span>
)}
<StatusMarker status={row.status} />
</div>
);
}
Expand All @@ -65,11 +84,12 @@ function ToolActivityLine({
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<StatusMarker status={row.status} />
<ProviderTile provider={row.provider} />
<span className="chat-tool-activity-phrase">{row.phrase}</span>
{row.meta === undefined ? null : (
<span className="chat-tool-activity-meta">{row.meta}</span>
)}
<StatusMarker status={row.status} />
<CaretRight
className="chat-tool-activity-caret"
data-open={open}
Expand All @@ -82,51 +102,22 @@ function ToolActivityLine({
}

/**
* A run of consecutive tool calls, collapsed to the one line that says
* what the round amounted to. A single call needs no round chrome — it is
* already one line — so it renders on its own.
* A run of consecutive tool calls, stacked one chip per call — never
* folded into a summary line (§12.3: chips are not collapsibles). Each
* chip keeps its own disclosure onto its detail; there is no group-level
* trigger and no count of how many calls happened.
*/
export function ToolActivityGroup({
rows,
}: {
readonly rows: readonly ToolActivityRow[];
}) {
const round = describeToolRound(rows);
const [open, setOpen] = useState(round.opensByDefault);

if (rows.length === 0) return null;
const onlyRow = rows[0];
if (rows.length === 1 && onlyRow !== undefined) {
return (
<div className="chat-tool-activity">
<ToolActivityLine row={onlyRow} indented={false} />
</div>
);
}

return (
<div className="chat-tool-activity" data-round="true">
<button
type="button"
className="chat-tool-activity-trigger"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<StatusMarker status={round.status} />
<span className="chat-tool-activity-phrase">{round.label}</span>
<CaretRight
className="chat-tool-activity-caret"
data-open={open}
aria-hidden="true"
/>
</button>
{open ? (
<div className="chat-tool-activity-rows">
{rows.map((row) => (
<ToolActivityLine key={row.key} row={row} indented />
))}
</div>
) : null}
<div className="chat-tool-activity">
{rows.map((row) => (
<ToolActivityLine key={row.key} row={row} indented={false} />
))}
</div>
);
}
Expand Down
Loading
Loading