@workerdeck/ui 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Pencil, Search, Trash2, X } from 'lucide-react'
3
+ import {
4
+ STATE_LABELS,
5
+ STATE_ORDER,
6
+ adaptersOf,
7
+ clearFilters,
8
+ filterRows,
9
+ groupRows,
10
+ hasFacetFilter,
11
+ sessionLabel,
12
+ subsetSummary,
13
+ } from '@workerdeck/protocol'
14
+ import type {
15
+ GroupBy,
16
+ SessionRow,
17
+ SessionState,
18
+ SortBy,
19
+ ViewConfig,
20
+ WorkspaceScope,
21
+ } from '@workerdeck/protocol'
22
+ import { Badge } from '../ui/Badge.tsx'
23
+ import { Button } from '../ui/Button.tsx'
24
+ import { Input } from '../ui/Input.tsx'
25
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
26
+ import { cn } from '../../lib/utils.ts'
27
+ import { formatCost, formatRelativeTime } from '../../lib/format.ts'
28
+ import { STATUS_META } from './status.ts'
29
+
30
+ /**
31
+ * A sessions list with the affordances a list of thirty needs: search, facets,
32
+ * grouping, sorting, unread counts, and one honest line about what is hidden.
33
+ *
34
+ * The *rules* are `@workerdeck/protocol`'s (`filterRows`/`groupRows`/
35
+ * `subsetSummary`), not this component's — the VS Code sidebar renders the same
36
+ * model with workbench chrome, its activity-bar badge counts the same rows this
37
+ * would show, and iOS mirrors them in Swift. What lives here is the styled
38
+ * rendering of that model, so a host that wants the dashboard's look gets it
39
+ * without reimplementing the model behind it.
40
+ *
41
+ * `SessionList` remains beside this for the plain case (a fixed set of rows, no
42
+ * controls); this is what you reach for when the list is the screen.
43
+ */
44
+
45
+ export interface SessionBrowserProps {
46
+ rows: SessionRow[]
47
+ config: ViewConfig
48
+ onConfigChange: (config: ViewConfig) => void
49
+ /** The host's own folders, if it has such a notion. Absent — a dashboard, a
50
+ * phone — makes the scope filter genuinely inert rather than empty. */
51
+ scope?: WorkspaceScope
52
+ activeId?: string
53
+ onSelect?: (row: SessionRow) => void
54
+ onDelete?: (row: SessionRow) => void
55
+ /**
56
+ * Rename, from the row's pencil. Empty string restores the derived title. A
57
+ * gateway edit (`PATCH /sessions/:id`), never a local override — every client
58
+ * should see the same name. Omit to make titles read-only.
59
+ *
60
+ * A hover affordance rather than the extension's double-click-the-title,
61
+ * because here a single click on the row navigates: the *first* click of a
62
+ * double-click would have already left the page.
63
+ */
64
+ onRename?: (row: SessionRow, title: string) => void
65
+ /** Rendered when nothing at all exists (as opposed to nothing matching). */
66
+ emptyState?: React.ReactNode
67
+ className?: string
68
+ }
69
+
70
+ export function SessionBrowser({
71
+ rows,
72
+ config,
73
+ onConfigChange,
74
+ scope,
75
+ activeId,
76
+ onSelect,
77
+ onDelete,
78
+ onRename,
79
+ emptyState,
80
+ className,
81
+ }: SessionBrowserProps) {
82
+ const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
83
+ const groups = useMemo(() => groupRows(visible, config), [visible, config])
84
+ const subset = subsetSummary(config, scope, visible.length, rows.length)
85
+ // Derived, not enumerated: a new engine or a new gateway needs no change here,
86
+ // and a facet with one possible value is not a choice worth showing.
87
+ const adapters = useMemo(() => adaptersOf(rows), [rows])
88
+ const gateways = useMemo(() => {
89
+ const seen = new Map<string, string>()
90
+ for (const row of rows) seen.set(row.hostId, row.hostName)
91
+ return [...seen].map(([id, name]) => ({ id, name }))
92
+ }, [rows])
93
+
94
+ const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
95
+
96
+ return (
97
+ <div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
98
+ <div className='flex flex-wrap items-center gap-2'>
99
+ <div className='relative min-w-48 flex-1'>
100
+ <Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
101
+ <Input
102
+ value={config.search}
103
+ onChange={(e) => set({ search: e.target.value })}
104
+ placeholder='Search sessions'
105
+ aria-label='Search sessions'
106
+ className='pl-8'
107
+ />
108
+ </div>
109
+ <FacetSelect
110
+ label='State'
111
+ value={config.states}
112
+ options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
113
+ onChange={(states) => set({ states: states as SessionState[] })}
114
+ />
115
+ {adapters.length > 1 ? (
116
+ <FacetSelect
117
+ label='Engine'
118
+ value={config.adapters}
119
+ options={adapters.map((a) => ({ value: a, label: a }))}
120
+ onChange={(adapters) => set({ adapters })}
121
+ />
122
+ ) : null}
123
+ {gateways.length > 1 ? (
124
+ <FacetSelect
125
+ label='Gateway'
126
+ value={config.gateways}
127
+ options={gateways.map((g) => ({ value: g.id, label: g.name }))}
128
+ onChange={(gateways) => set({ gateways })}
129
+ />
130
+ ) : null}
131
+ <OneOfSelect
132
+ label='Group'
133
+ value={config.groupBy}
134
+ options={[
135
+ { value: 'none', label: 'No grouping' },
136
+ { value: 'state', label: 'By state' },
137
+ { value: 'adapter', label: 'By engine' },
138
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
139
+ ]}
140
+ onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
141
+ />
142
+ <OneOfSelect
143
+ label='Sort'
144
+ value={config.sortBy}
145
+ options={[
146
+ { value: 'recent', label: 'Recent' },
147
+ { value: 'name', label: 'Name' },
148
+ { value: 'state', label: 'State' },
149
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
150
+ ]}
151
+ onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
152
+ />
153
+ </div>
154
+
155
+ {subset ? (
156
+ <div className='flex items-center gap-2 text-label text-fg-4'>
157
+ <span>
158
+ {subset.shown} of {subset.total}
159
+ {subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
160
+ </span>
161
+ <button
162
+ type='button'
163
+ className='text-fg-3 underline underline-offset-2 hover:text-fg-1'
164
+ onClick={() => onConfigChange(clearFilters(config))}>
165
+ Show all
166
+ </button>
167
+ </div>
168
+ ) : null}
169
+
170
+ {rows.length === 0 ? (
171
+ (emptyState ?? <Empty>No sessions yet.</Empty>)
172
+ ) : visible.length === 0 ? (
173
+ // Two different dead ends, two different ways out. "Nothing matches" is
174
+ // a filter someone set; anything else is the state of the world.
175
+ <Empty>
176
+ {hasFacetFilter(config) ? (
177
+ <>
178
+ No sessions match.{' '}
179
+ <button
180
+ type='button'
181
+ className='underline underline-offset-2 hover:text-fg-1'
182
+ onClick={() => onConfigChange(clearFilters(config))}>
183
+ Clear filters
184
+ </button>
185
+ </>
186
+ ) : (
187
+ 'No sessions here.'
188
+ )}
189
+ </Empty>
190
+ ) : (
191
+ <div className='flex flex-col gap-4'>
192
+ {groups.map((group) => (
193
+ <div key={group.key} className='flex flex-col gap-1'>
194
+ {config.groupBy !== 'none' && group.label ? (
195
+ <div className='flex items-baseline gap-2 px-2.5 text-label font-medium text-fg-4'>
196
+ <span className='uppercase tracking-wide'>{group.label}</span>
197
+ <span className='text-fg-4/70'>{group.rows.length}</span>
198
+ </div>
199
+ ) : null}
200
+ {group.rows.map((row) => (
201
+ <SessionRowItem
202
+ key={`${row.hostId}:${row.info.id}`}
203
+ row={row}
204
+ active={row.info.id === activeId}
205
+ showGateway={gateways.length > 1}
206
+ onSelect={onSelect}
207
+ onDelete={onDelete}
208
+ onRename={onRename}
209
+ />
210
+ ))}
211
+ </div>
212
+ ))}
213
+ </div>
214
+ )}
215
+ </div>
216
+ )
217
+ }
218
+
219
+ function Empty({ children }: { children: React.ReactNode }) {
220
+ return <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{children}</div>
221
+ }
222
+
223
+ interface SessionRowItemProps {
224
+ row: SessionRow
225
+ active?: boolean
226
+ showGateway?: boolean
227
+ onSelect?: (row: SessionRow) => void
228
+ onDelete?: (row: SessionRow) => void
229
+ onRename?: (row: SessionRow, title: string) => void
230
+ }
231
+
232
+ function SessionRowItem({
233
+ row,
234
+ active,
235
+ showGateway,
236
+ onSelect,
237
+ onDelete,
238
+ onRename,
239
+ }: SessionRowItemProps) {
240
+ const { info } = row
241
+ const meta = STATUS_META[info.status]
242
+ const [editing, setEditing] = useState(false)
243
+
244
+ return (
245
+ <div
246
+ data-slot='session-row'
247
+ data-active={active || undefined}
248
+ className={cn(
249
+ 'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
250
+ active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
251
+ )}>
252
+ <div className='min-w-0 flex-1'>
253
+ <div className='flex items-center gap-2'>
254
+ {/* The editor replaces the link rather than sitting inside it — an
255
+ input nested in a button is invalid, and disabling the button to
256
+ protect the edit disables the field with it. */}
257
+ {editing && onRename ? (
258
+ <NameEditor
259
+ initial={info.title ?? ''}
260
+ onCommit={(title) => {
261
+ setEditing(false)
262
+ onRename(row, title)
263
+ }}
264
+ onCancel={() => setEditing(false)}
265
+ />
266
+ ) : (
267
+ <button
268
+ type='button'
269
+ onClick={() => onSelect?.(row)}
270
+ className='min-w-0 truncate text-left text-body-sm font-medium text-fg-1 outline-none'>
271
+ {sessionLabel(info)}
272
+ </button>
273
+ )}
274
+ <Badge variant={meta.variant} dot className='shrink-0'>
275
+ {meta.label}
276
+ </Badge>
277
+ {row.unseen > 0 ? (
278
+ // Transcript rows since this session was last on screen — the same
279
+ // unit the VS Code badge counts, because turns undercount badly.
280
+ <Badge variant='accent' className='shrink-0' title={`${row.unseen} new`}>
281
+ {row.unseen}
282
+ </Badge>
283
+ ) : null}
284
+ </div>
285
+ <button
286
+ type='button'
287
+ onClick={() => !editing && onSelect?.(row)}
288
+ className='mt-0.5 flex w-full items-center gap-2 text-left font-mono text-label text-fg-4 outline-none'>
289
+ <span className='truncate'>{info.cwd}</span>
290
+ {showGateway ? <span className='shrink-0'>{row.hostName}</span> : null}
291
+ {info.profile ? <span className='shrink-0'>@{info.profile}</span> : null}
292
+ <span className='shrink-0'>{formatCost(info.totalCostUsd)}</span>
293
+ <span className='shrink-0'>
294
+ {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
295
+ </span>
296
+ </button>
297
+ </div>
298
+ {onRename && !editing ? (
299
+ <Button
300
+ variant='ghost'
301
+ size='icon-sm'
302
+ aria-label='Rename session'
303
+ className='opacity-0 transition-opacity group-hover:opacity-100'
304
+ onClick={() => setEditing(true)}>
305
+ <Pencil className='size-3.5 text-fg-3' />
306
+ </Button>
307
+ ) : null}
308
+ {onDelete ? (
309
+ <Button
310
+ variant='ghost'
311
+ size='icon-sm'
312
+ aria-label='Close session'
313
+ className='opacity-0 transition-opacity group-hover:opacity-100'
314
+ onClick={() => onDelete(row)}>
315
+ <Trash2 className='size-3.5 text-fg-3' />
316
+ </Button>
317
+ ) : null}
318
+ </div>
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
324
+ * that is still inside this document: switching windows must not silently commit,
325
+ * nor kill the editor in the frame it opened.
326
+ */
327
+ function NameEditor({
328
+ initial,
329
+ onCommit,
330
+ onCancel,
331
+ }: {
332
+ initial: string
333
+ onCommit: (title: string) => void
334
+ onCancel: () => void
335
+ }) {
336
+ const [value, setValue] = useState(initial)
337
+ const ref = useRef<HTMLInputElement>(null)
338
+ useEffect(() => {
339
+ ref.current?.select()
340
+ }, [])
341
+ return (
342
+ <input
343
+ ref={ref}
344
+ value={value}
345
+ autoFocus
346
+ aria-label='Session name'
347
+ // The row is a button; a click in here must not select the session.
348
+ onClick={(e) => e.stopPropagation()}
349
+ onChange={(e) => setValue(e.target.value)}
350
+ onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
351
+ onKeyDown={(e) => {
352
+ if (e.key === 'Enter') onCommit(value.trim())
353
+ else if (e.key === 'Escape') onCancel()
354
+ e.stopPropagation()
355
+ }}
356
+ className='-my-0.5 min-w-0 flex-1 rounded-sm border border-ring bg-bg px-1 py-0.5 text-body-sm font-medium text-fg-1 outline-none'
357
+ />
358
+ )
359
+ }
360
+
361
+ /** A multi-select facet. Empty = no filter, which is why the trigger reads the
362
+ * facet's name rather than "All": nothing is being excluded. */
363
+ function FacetSelect({
364
+ label,
365
+ value,
366
+ options,
367
+ onChange,
368
+ }: {
369
+ label: string
370
+ value: string[]
371
+ options: { value: string; label: string }[]
372
+ onChange: (value: string[]) => void
373
+ }) {
374
+ return (
375
+ <div className='flex items-center gap-1'>
376
+ <Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
377
+ <SelectTrigger aria-label={label} className='min-w-28'>
378
+ <SelectValue>
379
+ {value.length === 0
380
+ ? label
381
+ : value.length === 1
382
+ ? (options.find((o) => o.value === value[0])?.label ?? label)
383
+ : `${label} · ${value.length}`}
384
+ </SelectValue>
385
+ </SelectTrigger>
386
+ <SelectContent>
387
+ {options.map((option) => (
388
+ <SelectItem key={option.value} value={option.value}>
389
+ <SelectItemText>{option.label}</SelectItemText>
390
+ </SelectItem>
391
+ ))}
392
+ </SelectContent>
393
+ </Select>
394
+ {value.length > 0 ? (
395
+ <Button variant='ghost' size='icon-sm' aria-label={`Clear ${label}`} onClick={() => onChange([])}>
396
+ <X className='size-3 text-fg-4' />
397
+ </Button>
398
+ ) : null}
399
+ </div>
400
+ )
401
+ }
402
+
403
+ function OneOfSelect({
404
+ label,
405
+ value,
406
+ options,
407
+ onChange,
408
+ }: {
409
+ label: string
410
+ value: string
411
+ options: readonly { value: string; label: string }[]
412
+ onChange: (value: string) => void
413
+ }) {
414
+ return (
415
+ <Select value={value} onValueChange={(v) => onChange(v as string)}>
416
+ <SelectTrigger aria-label={label} className='min-w-28'>
417
+ <SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
418
+ </SelectTrigger>
419
+ <SelectContent>
420
+ {options.map((option) => (
421
+ <SelectItem key={option.value} value={option.value}>
422
+ <SelectItemText>{option.label}</SelectItemText>
423
+ </SelectItem>
424
+ ))}
425
+ </SelectContent>
426
+ </Select>
427
+ )
428
+ }
@@ -49,7 +49,12 @@ import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
49
49
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
50
50
  import { StatusBar } from './StatusBar.tsx'
51
51
  import { Transcript } from './Transcript.tsx'
52
- import { TranscriptVariantProvider, type TranscriptVariant } from './transcript-variant.tsx'
52
+ import {
53
+ TranscriptDensityProvider,
54
+ TranscriptVariantProvider,
55
+ type TranscriptDensity,
56
+ type TranscriptVariant,
57
+ } from './transcript-variant.tsx'
53
58
  import { UsageDialog } from './UsageDialog.tsx'
54
59
 
55
60
  export interface SessionPanelProps {
@@ -107,6 +112,14 @@ export interface SessionPanelProps {
107
112
  * (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
108
113
  */
109
114
  transcriptVariant?: TranscriptVariant
115
+ /**
116
+ * How much air the transcript gives each row — `'comfortable'` (default: a
117
+ * blank line between messages, as the Claude Code CLI leaves) or `'compact'`
118
+ * (rows tight against one another). Independent of `transcriptVariant`: the
119
+ * variant follows from the surface, density is the reader's preference, and a
120
+ * dock is allowed to be roomy.
121
+ */
122
+ transcriptDensity?: TranscriptDensity
110
123
  /**
111
124
  * Where the session's own controls — model and permission mode — live.
112
125
  * `'internal'` (default) draws them in the composer's toolbar row.
@@ -156,6 +169,15 @@ export type SessionControls = {
156
169
  setModel: (model?: string) => void
157
170
  setPermissionMode: (mode: PermissionMode) => void
158
171
  interrupt: () => void
172
+ /**
173
+ * Put the caret in the composer.
174
+ *
175
+ * For an embedder whose own chrome is how you arrive at a session — clicking a
176
+ * row in VS Code's sidebar — where revealing the panel and being able to type
177
+ * are the same intention. The panel cannot infer it: from in here, a session
178
+ * appearing looks identical whether someone asked for it or it was restored.
179
+ */
180
+ focusComposer: () => void
159
181
  }
160
182
 
161
183
  /** Everything a click can mean other than "put the caret in the composer".
@@ -232,6 +254,7 @@ export function SessionPanel({
232
254
  onOpenPanel,
233
255
  onVitals,
234
256
  transcriptVariant = 'cards',
257
+ transcriptDensity = 'comfortable',
235
258
  controlsSurface = 'internal',
236
259
  onControls,
237
260
  focusComposerOnClick = false,
@@ -358,6 +381,7 @@ export function SessionPanel({
358
381
  setModel: (model) => setters.current.setModel(model),
359
382
  setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
360
383
  interrupt: () => setters.current.interrupt(),
384
+ focusComposer: () => composerRef.current?.focus(),
361
385
  })
362
386
  useEffect(() => {
363
387
  const handler = onControlsRef.current
@@ -495,6 +519,7 @@ export function SessionPanel({
495
519
  // and question prompts live outside the scroller but are line items in the
496
520
  // same run, and they read `useLines()` like every other row.
497
521
  <TranscriptVariantProvider value={transcriptVariant}>
522
+ <TranscriptDensityProvider value={transcriptDensity}>
498
523
  <div
499
524
  data-slot='session-panel'
500
525
  onClick={handleClick}
@@ -528,6 +553,7 @@ export function SessionPanel({
528
553
  canBrowseFiles={hostFiles.available}
529
554
  hostImage={hostImage}
530
555
  variant={transcriptVariant}
556
+ density={transcriptDensity}
531
557
  catchUp={
532
558
  catchUp && newCount > 0
533
559
  ? { from: catchUp.itemCount, since: catchUp.since }
@@ -686,6 +712,7 @@ export function SessionPanel({
686
712
  </>
687
713
  ) : null}
688
714
  </div>
715
+ </TranscriptDensityProvider>
689
716
  </TranscriptVariantProvider>
690
717
  )
691
718
  }
@@ -24,10 +24,29 @@ export interface SessionWorkspaceProps {
24
24
  /** Passed straight through to {@link SessionPanel} — including the render-prop
25
25
  * form that claims the session-actions menu. */
26
26
  header?: SessionPanelProps['header']
27
+ /**
28
+ * Panel seams the workspace does not interpret, forwarded verbatim.
29
+ *
30
+ * They are listed rather than spread so the workspace stays explicit about
31
+ * what it passes on: the panel owns the session's one attach, and a seam that
32
+ * silently arrived here would be a second place to look for why a session
33
+ * renders the way it does.
34
+ */
35
+ transcriptVariant?: SessionPanelProps['transcriptVariant']
36
+ transcriptDensity?: SessionPanelProps['transcriptDensity']
37
+ unseen?: SessionPanelProps['unseen']
38
+ onVitals?: SessionPanelProps['onVitals']
27
39
  /** Rail width in pixels on first render. */
28
40
  defaultRailWidth?: number
29
41
  /** Start with the file rail collapsed even on a wide viewport. */
30
42
  defaultRailCollapsed?: boolean
43
+ /**
44
+ * The rail moved. Paired with the two defaults so an embedder can persist the
45
+ * layout — the workspace deliberately does not, because *where* to keep it (a
46
+ * Memento, localStorage, a workspace file) is the embedder's call, and a
47
+ * component that picked one would be wrong in the other hosts.
48
+ */
49
+ onRailChange?: (rail: { width: number; collapsed: boolean }) => void
31
50
  className?: string
32
51
  }
33
52
 
@@ -64,8 +83,13 @@ export function SessionWorkspace({
64
83
  client,
65
84
  sessionId,
66
85
  header,
86
+ transcriptVariant,
87
+ transcriptDensity,
88
+ unseen,
89
+ onVitals,
67
90
  defaultRailWidth = 260,
68
91
  defaultRailCollapsed,
92
+ onRailChange,
69
93
  className,
70
94
  }: SessionWorkspaceProps) {
71
95
  // The cwd is the tree's root, and it comes from the registry rather than from
@@ -95,6 +119,13 @@ export function SessionWorkspace({
95
119
  const wide = useIsWide()
96
120
  const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
97
121
  const [railWidth, setRailWidth] = useState(defaultRailWidth)
122
+ // Reported rather than stored. Kept in a ref so the effect below fires on a
123
+ // real change instead of on every render an inline callback would cause.
124
+ const onRailChangeRef = useRef(onRailChange)
125
+ onRailChangeRef.current = onRailChange
126
+ useEffect(() => {
127
+ onRailChangeRef.current?.({ width: railWidth, collapsed: railCollapsed })
128
+ }, [railWidth, railCollapsed])
98
129
  const [editorHeight, setEditorHeight] = useState(360)
99
130
 
100
131
  // Closing every tab returns the agent to the full column; opening one again
@@ -231,6 +262,10 @@ export function SessionWorkspace({
231
262
  client={client}
232
263
  sessionId={sessionId}
233
264
  header={hoisted}
265
+ transcriptVariant={transcriptVariant}
266
+ transcriptDensity={transcriptDensity}
267
+ unseen={unseen}
268
+ onVitals={onVitals}
234
269
  className='min-h-0 flex-1'
235
270
  />
236
271
  </div>
@@ -8,6 +8,7 @@ import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
9
  import { isMutatingTool, toolIcon } from '../../lib/tool-icon.ts'
10
10
  import { LINE_INDENT, LinePayload } from './line-prompt.tsx'
11
+ import { usePulse } from './pulse.tsx'
11
12
  import { LineGlyph, useLines } from './transcript-variant.tsx'
12
13
 
13
14
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
@@ -97,6 +98,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
97
98
  const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
98
99
  const badge = STATE_BADGE[status]
99
100
  const isError = status === 'failed' || item.result?.isError === true
101
+ // Ticks only while this row is actually running, and only in the variant with a
102
+ // gutter to pulse in — an idle transcript of a hundred settled tools starts no
103
+ // timers at all.
104
+ const pulse = usePulse(lines && badge.busy)
100
105
  const Icon = toolIcon(item.name)
101
106
 
102
107
  const resultText = item.result?.text ?? ''
@@ -162,7 +167,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
162
167
  ? 'text-success'
163
168
  : STATE_GLYPH[status]
164
169
  }>
165
- {badge.busy ? '◐' : '●'}
170
+ {/* Running: the mark's own pulse, so a working tool row and the
171
+ transcript's working line beat together. Settled: a plain dot,
172
+ which reads as "done" precisely by not moving. */}
173
+ {badge.busy ? pulse : '●'}
166
174
  </LineGlyph>
167
175
  <span className='min-w-0 flex-1 truncate text-body-sm leading-5 text-fg-3'>
168
176
  <span className='font-medium text-fg-1'>{item.name}</span>
@@ -171,7 +179,8 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
171
179
  {item.backend && item.backend !== 'server' ? (
172
180
  <span className='shrink-0 text-label text-fg-4'>{item.backend}</span>
173
181
  ) : null}
174
- {badge.busy ? <Spinner className='size-3 shrink-0 self-center text-fg-4' /> : null}
182
+ {/* No Spinner here: the gutter glyph animates now, and two spinners on
183
+ one row is one too many. `cards` keeps its own — it has no gutter. */}
175
184
  {status === 'deferred' ? <Clock className='size-3 shrink-0 self-center text-fg-4' /> : null}
176
185
  {isError && !badge.busy ? (
177
186
  <span className='shrink-0 text-label text-danger'>error</span>