4646 * rhythm — is the card's business and must stay in the card. Anything about
4747 * how the card sits in the transcript — full width, inset, distance to its
4848 * neighbours — is `ChatCardSlot`'s, and a card must not set it.
49- * 4. **Role drives alignment and nothing else.** `role="user"` is right-aligned
50- * in a grey bubble; `role="assistant"` is left-aligned, full width and
51- * unboxed — plain prose, so the cards are the only things that read as cards.
49+ * 4. **Role drives alignment and surface.** `role="user"` is right-aligned in an
50+ * indigo bubble; `role="assistant"` is left-aligned and near-full width in a
51+ * soft dark card. Rich cards are still distinct — they carry headers, badges
52+ * and actions inside the same border language.
53+ * 5. **Machine text is mono.** Anything the system names — a tool call, a step
54+ * count, a duration — is set in mono; sentences the agent writes stay sans.
5255 */
56+ import { ChevronRightIcon } from "@heroicons/react/20/solid" ;
5357import type { Ref } from "react" ;
5458import { createContext , Suspense , useContext } from "react" ;
5559import { StreamdownRenderer } from "~/components/code/StreamdownRenderer" ;
@@ -74,6 +78,22 @@ const UNIT_GAP = "space-y-1.5";
7478const SCROLLER =
7579 "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control" ;
7680
81+ /**
82+ * The surface a turn's own content sits on: a soft dark card, one step above the
83+ * panel. Used by the assistant's text and by the pills, so a transcript reads as
84+ * one material.
85+ */
86+ const SOFT_SURFACE = "border border-border-bright bg-background-hover" ;
87+
88+ /** Machine text — a tool name, a step count, a duration. See rule 5. */
89+ const MONO_TEXT = "font-mono text-xs tracking-wide" ;
90+
91+ /**
92+ * A pill: a tool call, in flight or landed. Fully rounded and dashed, so it reads
93+ * as a trace of what happened rather than as a control to press.
94+ */
95+ const PILL = "inline-flex min-w-0 max-w-full items-center rounded-full border-dashed px-3 py-1" ;
96+
7797/**
7898 * The soft edge at the bottom of the scroller. There is no rule above the
7999 * composer — the transcript runs behind it — so this is what says the content
@@ -165,27 +185,28 @@ export function ChatTurn({
165185/**
166186 * A text body.
167187 *
168- * The assistant variant is the panel's markdown path, rendered as plain prose:
169- * NOT a card. The agent answers in text most of the time, and a box around every
170- * one of those answers made the transcript read as a stack of cards — so the box
171- * is reserved for the things that really are cards (report, investigation,
172- * diagnosis, chart), which mount through `ChatCardSlot`. A plain-text fallback
173- * shows while the markdown renderer loads.
188+ * The assistant variant is the panel's markdown path, in a soft card: near-full
189+ * width, left-aligned, so an answer has an edge without shouting. A plain-text
190+ * fallback shows while the markdown renderer loads.
174191 *
175- * The user variant is the one bubble left: grey, right-aligned, so a turn is
176- * legible at a glance without competing with the accent the agent's own surfaces
177- * use.
192+ * The user variant is the accent bubble: indigo, right-aligned, tighter than full
193+ * width, so who said what is legible at a glance.
178194 */
179195export function ChatText ( { role = "assistant" , text } : { role ?: ChatRole ; text : string } ) {
180196 if ( role === "user" ) {
181197 return (
182- < div className = "max-w-[80 %] rounded-lg bg-background-raised px-4 py-2.5 text-sm text-text-bright " >
198+ < div className = "max-w-[85 %] rounded-2xl bg-indigo-600 px-4 py-2.5 text-sm text-white " >
183199 < div className = "whitespace-pre-wrap wrap-anywhere" > { text } </ div >
184200 </ div >
185201 ) ;
186202 }
187203 return (
188- < div className = "streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed wrap-anywhere" >
204+ < div
205+ className = { cn (
206+ SOFT_SURFACE ,
207+ "streamdown-container min-w-0 rounded-xl px-4 py-3 font-sans text-sm font-normal text-text-bright wrap-anywhere"
208+ ) }
209+ >
189210 < Suspense fallback = { < span className = "whitespace-pre-wrap" > { text } </ span > } >
190211 < StreamdownRenderer > { text } </ StreamdownRenderer >
191212 </ Suspense >
@@ -203,7 +224,8 @@ export function ChatCardSlot({ children }: { children: React.ReactNode }) {
203224}
204225
205226/**
206- * The progress line: a spinner and one line of dimmed text, left-aligned.
227+ * The progress line: a spinner and one line of dimmed mono text, left-aligned —
228+ * the same register as the step summary a finished turn leaves behind.
207229 *
208230 * It always carries the transcript's inset — either from the turn it sits in, or
209231 * by applying it itself when it is mounted loose (under a card, say). There is no
@@ -216,9 +238,9 @@ export function ChatCardSlot({ children }: { children: React.ReactNode }) {
216238export function ChatProgress ( { children } : { children : React . ReactNode } ) {
217239 const insetClass = useInsetClass ( ) ;
218240 return (
219- < div className = { cn ( insetClass , "flex items-start text-sm text-text-dimmed" , ROW_GAP ) } >
220- { /* text-sm line box is 20px , the spinner 12px: 4px centres it on line one. */ }
221- < Spinner className = "mt-1 size-3 shrink-0" />
241+ < div className = { cn ( insetClass , MONO_TEXT , "flex items-start text-text-dimmed" , ROW_GAP ) } >
242+ { /* text-xs line box is 16px , the spinner 12px: 2px centres it on line one. */ }
243+ < Spinner className = "mt-0.5 size-3 shrink-0" />
222244 { children }
223245 </ div >
224246 ) ;
@@ -238,26 +260,60 @@ export function ChatPendingTool({ label }: { label: string }) {
238260 const insetClass = useInsetClass ( ) ;
239261 return (
240262 < div className = { cn ( insetClass , "flex min-w-0" ) } >
241- < span
242- className = { cn (
243- "inline-flex h-6 min-w-0 items-center rounded-full border border-border-bright bg-background-bright px-2.5 text-xs text-text-dimmed" ,
244- CHIP_GAP
245- ) }
246- >
263+ < span className = { cn ( PILL , SOFT_SURFACE , MONO_TEXT , "text-text-dimmed" , CHIP_GAP ) } >
247264 < Spinner className = "size-3 shrink-0" />
248265 < span className = "truncate" > { label } </ span >
249266 </ span >
250267 </ div >
251268 ) ;
252269}
253270
271+ /** How a landed tool call ended. Carried by the pill's dot, not by its text. */
272+ export type ChatToolTone = "done" | "error" ;
273+
274+ const TOOL_DOT : Record < ChatToolTone , string > = {
275+ done : "bg-primary" ,
276+ error : "bg-error" ,
277+ } ;
278+
254279/**
255- * A tool-call row, and optionally a `ChatProgress` under it while the call is in
256- * flight. Nothing but the row's placement lives here — the row itself is the
257- * shared `ToolUseRow`.
280+ * A landed tool call, as a pill: a dot for how it ended, what it did in bright
281+ * mono, and what it did it to in dimmed mono. The chevron marks a pill whose
282+ * detail is longer than the room it has.
283+ *
284+ * `children` is anything that belongs under the row — a `ChatProgress` while the
285+ * turn continues, say.
258286 */
259- export function ChatToolRow ( { children } : { children : React . ReactNode } ) {
260- return < div className = { cn ( "min-w-0" , TURN_BODY_GAP ) } > { children } </ div > ;
287+ export function ChatToolRow ( {
288+ label,
289+ detail,
290+ tone = "done" ,
291+ children,
292+ } : {
293+ label ?: React . ReactNode ;
294+ /** What the call was about: a query, an error message, a run id. Truncated. */
295+ detail ?: React . ReactNode ;
296+ tone ?: ChatToolTone ;
297+ children ?: React . ReactNode ;
298+ } ) {
299+ const insetClass = useInsetClass ( ) ;
300+ return (
301+ < div className = { cn ( insetClass , "min-w-0" , TURN_BODY_GAP ) } >
302+ { label ? (
303+ < div className = "flex min-w-0" >
304+ < span className = { cn ( PILL , SOFT_SURFACE , MONO_TEXT , CHIP_GAP ) } >
305+ < span aria-hidden className = { cn ( "size-1.5 shrink-0 rounded-full" , TOOL_DOT [ tone ] ) } />
306+ < span className = "shrink-0 text-text-bright" > { label } </ span >
307+ { detail ? < span className = "truncate text-text-dimmed" > { detail } </ span > : null }
308+ { detail ? (
309+ < ChevronRightIcon aria-hidden className = "size-3 shrink-0 text-text-faint" />
310+ ) : null }
311+ </ span >
312+ </ div >
313+ ) : null }
314+ { children }
315+ </ div >
316+ ) ;
261317}
262318
263319/**
0 commit comments