@workerdeck/ui 0.11.0 → 0.13.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.
Files changed (35) hide show
  1. package/build/{SessionPanel-_U8tjX29.mjs → SessionPanel-CKQa4i0Y.mjs} +337 -269
  2. package/build/SessionPanel-CKQa4i0Y.mjs.map +1 -0
  3. package/build/{SessionPanel-CyhygZx_.d.mts → SessionPanel-CZMA44NM.d.mts} +73 -2
  4. package/build/format.d.mts +65 -1
  5. package/build/format.mjs +118 -1
  6. package/build/format.mjs.map +1 -0
  7. package/build/index.d.mts +129 -10
  8. package/build/index.mjs +461 -5
  9. package/build/index.mjs.map +1 -1
  10. package/build/workspace.d.mts +36 -1
  11. package/build/workspace.mjs +16 -2
  12. package/build/workspace.mjs.map +1 -1
  13. package/package.json +4 -4
  14. package/src/components/agent/Composer.tsx +18 -10
  15. package/src/components/agent/EngineIcon.tsx +97 -0
  16. package/src/components/agent/Loader.tsx +11 -46
  17. package/src/components/agent/Message.tsx +10 -6
  18. package/src/components/agent/QuestionPrompt.tsx +1 -1
  19. package/src/components/agent/SessionBrowser.tsx +546 -0
  20. package/src/components/agent/SessionPanel.tsx +89 -14
  21. package/src/components/agent/SessionWorkspace.tsx +45 -0
  22. package/src/components/agent/StatusBar.tsx +9 -2
  23. package/src/components/agent/ToolCallCard.tsx +11 -2
  24. package/src/components/agent/Transcript.tsx +28 -11
  25. package/src/components/agent/line-prompt.tsx +2 -2
  26. package/src/components/agent/pulse.tsx +60 -0
  27. package/src/components/agent/transcript-variant.tsx +62 -0
  28. package/src/components/ui/Menu.tsx +1 -1
  29. package/src/components/ui/Select.tsx +1 -1
  30. package/src/components/ui/Tooltip.tsx +1 -1
  31. package/src/format.ts +1 -0
  32. package/src/index.ts +12 -0
  33. package/src/lib/status.ts +124 -0
  34. package/src/styles/theme.css +45 -15
  35. package/build/SessionPanel-_U8tjX29.mjs.map +0 -1
@@ -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 {
@@ -93,6 +98,17 @@ export interface SessionPanelProps {
93
98
  * take the menu, or it has nowhere left to go.
94
99
  */
95
100
  statusSurface?: 'internal' | 'external'
101
+ /**
102
+ * Which end of the panel the status bar sits at. Default `top`.
103
+ *
104
+ * `bottom` is the editor convention — VS Code's status bar runs along the
105
+ * foot of the window — and suits a host where the panel *is* the editor area
106
+ * and the chrome above it already belongs to the app. Placement only; the bar
107
+ * is the same bar, with the same `⋯` menu in its trailing slot, so this
108
+ * composes with {@link statusSurface} rather than competing with it (external
109
+ * still means "there isn't one").
110
+ */
111
+ statusPlacement?: 'top' | 'bottom'
96
112
  /** Where `panelSurface: 'external'` routes opens. Absent = the affordances
97
113
  * (status-bar clicks, `/mcp`) become inert rather than half-working. */
98
114
  onOpenPanel?: (panel: SessionSurfacePanel) => void
@@ -107,6 +123,14 @@ export interface SessionPanelProps {
107
123
  * (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
108
124
  */
109
125
  transcriptVariant?: TranscriptVariant
126
+ /**
127
+ * How much air the transcript gives each row — `'comfortable'` (default: a
128
+ * blank line between messages, as the Claude Code CLI leaves) or `'compact'`
129
+ * (rows tight against one another). Independent of `transcriptVariant`: the
130
+ * variant follows from the surface, density is the reader's preference, and a
131
+ * dock is allowed to be roomy.
132
+ */
133
+ transcriptDensity?: TranscriptDensity
110
134
  /**
111
135
  * Where the session's own controls — model and permission mode — live.
112
136
  * `'internal'` (default) draws them in the composer's toolbar row.
@@ -148,6 +172,22 @@ export interface SessionPanelProps {
148
172
  * the number to remember through `SessionVitals.itemCount`.
149
173
  */
150
174
  unseen?: { itemCount: number; since?: number }
175
+ /**
176
+ * A viewer, not a seat at the session: transcript, status bar and panels as
177
+ * usual, but no composer and no approval prompts.
178
+ *
179
+ * For a surface that is *about* a run rather than in it — the dashboard's job
180
+ * detail, where the session belongs to the queue and typing into it would be a
181
+ * second operator arriving mid-run. Deliberately not "disabled controls": a
182
+ * greyed-out composer says the session is busy, an absent one says this screen
183
+ * does not drive it. The attach is still live and read paths are untouched,
184
+ * so the transcript streams and the file tree browses.
185
+ *
186
+ * It does **not** claim to be an authorization boundary. Anything holding this
187
+ * client can still send; what it removes is the affordance, and the honest
188
+ * enforcement lives on the gateway.
189
+ */
190
+ readOnly?: boolean
151
191
  className?: string
152
192
  }
153
193
 
@@ -156,6 +196,15 @@ export type SessionControls = {
156
196
  setModel: (model?: string) => void
157
197
  setPermissionMode: (mode: PermissionMode) => void
158
198
  interrupt: () => void
199
+ /**
200
+ * Put the caret in the composer.
201
+ *
202
+ * For an embedder whose own chrome is how you arrive at a session — clicking a
203
+ * row in VS Code's sidebar — where revealing the panel and being able to type
204
+ * are the same intention. The panel cannot infer it: from in here, a session
205
+ * appearing looks identical whether someone asked for it or it was restored.
206
+ */
207
+ focusComposer: () => void
159
208
  }
160
209
 
161
210
  /** Everything a click can mean other than "put the caret in the composer".
@@ -229,13 +278,16 @@ export function SessionPanel({
229
278
  header,
230
279
  panelSurface = 'internal',
231
280
  statusSurface = 'internal',
281
+ statusPlacement = 'top',
232
282
  onOpenPanel,
233
283
  onVitals,
234
284
  transcriptVariant = 'cards',
285
+ transcriptDensity = 'comfortable',
235
286
  controlsSurface = 'internal',
236
287
  onControls,
237
288
  focusComposerOnClick = false,
238
289
  unseen,
290
+ readOnly = false,
239
291
  className,
240
292
  }: SessionPanelProps) {
241
293
  const external = panelSurface === 'external'
@@ -358,6 +410,7 @@ export function SessionPanel({
358
410
  setModel: (model) => setters.current.setModel(model),
359
411
  setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
360
412
  interrupt: () => setters.current.interrupt(),
413
+ focusComposer: () => composerRef.current?.focus(),
361
414
  })
362
415
  useEffect(() => {
363
416
  const handler = onControlsRef.current
@@ -479,11 +532,26 @@ export function SessionPanel({
479
532
  const menu = external ? null : actionsMenu
480
533
  const headerTakesActions = typeof header === 'function'
481
534
 
535
+ // Built once and placed at one end or the other — the bar has a `⋯` menu and
536
+ // three open handlers, and two copies of that in the tree would be two things
537
+ // to keep in step.
538
+ const statusBar = statusExternal ? null : (
539
+ <StatusBar
540
+ state={state}
541
+ connection={connection}
542
+ placement={statusPlacement}
543
+ onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
544
+ onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
545
+ onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
546
+ actions={headerTakesActions ? undefined : menu}
547
+ />
548
+ )
549
+
482
550
  // Dead-space clicks land in the composer. Anything the user actually aimed at
483
551
  // — a control, a link, the end of a drag-selection — keeps its own meaning;
484
552
  // this only claims what was left over.
485
553
  const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
486
- if (!focusComposerOnClick) return
554
+ if (!focusComposerOnClick || readOnly) return
487
555
  const target = event.target as HTMLElement | null
488
556
  if (target?.closest(INTERACTIVE)) return
489
557
  if (window.getSelection()?.isCollapsed === false) return
@@ -495,21 +563,13 @@ export function SessionPanel({
495
563
  // and question prompts live outside the scroller but are line items in the
496
564
  // same run, and they read `useLines()` like every other row.
497
565
  <TranscriptVariantProvider value={transcriptVariant}>
566
+ <TranscriptDensityProvider value={transcriptDensity}>
498
567
  <div
499
568
  data-slot='session-panel'
500
569
  onClick={handleClick}
501
570
  className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
502
571
  {headerTakesActions ? header({ actions: menu }) : header}
503
- {statusExternal ? null : (
504
- <StatusBar
505
- state={state}
506
- connection={connection}
507
- onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
508
- onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
509
- onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
510
- actions={headerTakesActions ? undefined : menu}
511
- />
512
- )}
572
+ {statusPlacement === 'top' ? statusBar : null}
513
573
  {protocolMismatch !== undefined ? (
514
574
  <Notice level='warning'>
515
575
  Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
@@ -528,6 +588,7 @@ export function SessionPanel({
528
588
  canBrowseFiles={hostFiles.available}
529
589
  hostImage={hostImage}
530
590
  variant={transcriptVariant}
591
+ density={transcriptDensity}
531
592
  catchUp={
532
593
  catchUp && newCount > 0
533
594
  ? { from: catchUp.itemCount, since: catchUp.since }
@@ -543,7 +604,12 @@ export function SessionPanel({
543
604
  <div
544
605
  data-slot='catch-up'
545
606
  className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
546
- <span aria-hidden className='select-none text-accent'>
607
+ <span
608
+ aria-hidden
609
+ className={cn(
610
+ 'select-none',
611
+ transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
612
+ )}>
547
613
 
548
614
  </span>
549
615
  <span className='min-w-0 flex-1 truncate'>
@@ -568,7 +634,10 @@ export function SessionPanel({
568
634
  {/* An engine with no approval channel never raises these, but a stale
569
635
  pending request from a replayed log would still render — the record is
570
636
  the authority on whether an approval UI means anything here. */}
571
- {capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
637
+ {/* A read-only surface has no answer to give: the status bar still says
638
+ `awaiting approval`, and the place that can act on it is wherever the
639
+ session is actually driven. */}
640
+ {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
572
641
  <div className='px-3 pb-2'>
573
642
  <div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
574
643
  {state.pendingApprovals.map((request) =>
@@ -592,6 +661,7 @@ export function SessionPanel({
592
661
  </div>
593
662
  </div>
594
663
  ) : null}
664
+ {readOnly ? null : (
595
665
  <Composer
596
666
  ref={composerRef}
597
667
  onSend={handleSend}
@@ -636,6 +706,10 @@ export function SessionPanel({
636
706
  )
637
707
  }
638
708
  />
709
+ )}
710
+ {/* Below the composer, along the foot of the panel — the editor
711
+ convention. Last in the flex column, so it is the bottom edge. */}
712
+ {statusPlacement === 'bottom' ? statusBar : null}
639
713
 
640
714
  {/* The internal dialog surface. The external one renders none of these —
641
715
  the embedder hosts equivalent surfaces and is handed the intents. */}
@@ -686,6 +760,7 @@ export function SessionPanel({
686
760
  </>
687
761
  ) : null}
688
762
  </div>
763
+ </TranscriptDensityProvider>
689
764
  </TranscriptVariantProvider>
690
765
  )
691
766
  }
@@ -24,10 +24,35 @@ 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
+ /** Which end of the panel the status bar sits at — see `SessionPanel`. */
38
+ statusPlacement?: SessionPanelProps['statusPlacement']
39
+ unseen?: SessionPanelProps['unseen']
40
+ /** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
41
+ * {@link SessionPanel}; the file tree and editor are unaffected, since reading
42
+ * a run's files is the point of a read-only view. */
43
+ readOnly?: SessionPanelProps['readOnly']
44
+ onVitals?: SessionPanelProps['onVitals']
27
45
  /** Rail width in pixels on first render. */
28
46
  defaultRailWidth?: number
29
47
  /** Start with the file rail collapsed even on a wide viewport. */
30
48
  defaultRailCollapsed?: boolean
49
+ /**
50
+ * The rail moved. Paired with the two defaults so an embedder can persist the
51
+ * layout — the workspace deliberately does not, because *where* to keep it (a
52
+ * Memento, localStorage, a workspace file) is the embedder's call, and a
53
+ * component that picked one would be wrong in the other hosts.
54
+ */
55
+ onRailChange?: (rail: { width: number; collapsed: boolean }) => void
31
56
  className?: string
32
57
  }
33
58
 
@@ -64,8 +89,15 @@ export function SessionWorkspace({
64
89
  client,
65
90
  sessionId,
66
91
  header,
92
+ transcriptVariant,
93
+ transcriptDensity,
94
+ statusPlacement,
95
+ unseen,
96
+ readOnly,
97
+ onVitals,
67
98
  defaultRailWidth = 260,
68
99
  defaultRailCollapsed,
100
+ onRailChange,
69
101
  className,
70
102
  }: SessionWorkspaceProps) {
71
103
  // The cwd is the tree's root, and it comes from the registry rather than from
@@ -95,6 +127,13 @@ export function SessionWorkspace({
95
127
  const wide = useIsWide()
96
128
  const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
97
129
  const [railWidth, setRailWidth] = useState(defaultRailWidth)
130
+ // Reported rather than stored. Kept in a ref so the effect below fires on a
131
+ // real change instead of on every render an inline callback would cause.
132
+ const onRailChangeRef = useRef(onRailChange)
133
+ onRailChangeRef.current = onRailChange
134
+ useEffect(() => {
135
+ onRailChangeRef.current?.({ width: railWidth, collapsed: railCollapsed })
136
+ }, [railWidth, railCollapsed])
98
137
  const [editorHeight, setEditorHeight] = useState(360)
99
138
 
100
139
  // Closing every tab returns the agent to the full column; opening one again
@@ -231,6 +270,12 @@ export function SessionWorkspace({
231
270
  client={client}
232
271
  sessionId={sessionId}
233
272
  header={hoisted}
273
+ transcriptVariant={transcriptVariant}
274
+ transcriptDensity={transcriptDensity}
275
+ statusPlacement={statusPlacement}
276
+ unseen={unseen}
277
+ readOnly={readOnly}
278
+ onVitals={onVitals}
234
279
  className='min-h-0 flex-1'
235
280
  />
236
281
  </div>
@@ -28,8 +28,11 @@ export interface StatusBarProps {
28
28
  onOpenStatus?: () => void
29
29
  onOpenContext?: () => void
30
30
  onOpenUsage?: () => void
31
- /** Trailing slot — the session-actions menu, in the panel's top-right. */
31
+ /** Trailing slot — the session-actions menu, at the bar's trailing edge. */
32
32
  actions?: ReactNode
33
+ /** Which edge the bar sits on, so its separating rule goes on the other side.
34
+ * Placement is the panel's decision; this only styles it. */
35
+ placement?: 'top' | 'bottom'
33
36
  className?: string
34
37
  }
35
38
 
@@ -149,6 +152,7 @@ export function StatusBar({
149
152
  onOpenContext,
150
153
  onOpenUsage,
151
154
  actions,
155
+ placement = 'top',
152
156
  className,
153
157
  }: StatusBarProps) {
154
158
  const meta = STATUS_META[state.status]
@@ -160,7 +164,10 @@ export function StatusBar({
160
164
  <div
161
165
  data-slot='status-bar'
162
166
  className={cn(
163
- 'flex items-center gap-2 border-b border-border bg-surface px-3 py-1.5',
167
+ 'flex items-center gap-2 border-border bg-surface px-3 py-1.5',
168
+ // The rule goes between the bar and the content, so which edge it sits
169
+ // on follows the placement.
170
+ placement === 'bottom' ? 'border-t' : 'border-b',
164
171
  className,
165
172
  )}>
166
173
  {/* One slot, two meanings: connection trouble wins it, because a session
@@ -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>
@@ -16,8 +16,10 @@ import { SessionEmptyState } from './SessionEmptyState.tsx'
16
16
  import { ToolCallCard } from './ToolCallCard.tsx'
17
17
  import {
18
18
  LineGlyph,
19
+ ROW_GAP,
19
20
  TranscriptVariantProvider,
20
21
  useLines,
22
+ type TranscriptDensity,
21
23
  type TranscriptVariant,
22
24
  } from './transcript-variant.tsx'
23
25
 
@@ -169,7 +171,7 @@ function RecapRow({ line, since }: { line: string; since?: number }) {
169
171
  if (lines) {
170
172
  return (
171
173
  <div data-slot='recap' className='flex items-baseline gap-2 py-0.5'>
172
- <LineGlyph className='text-accent'>※</LineGlyph>
174
+ <LineGlyph className='text-fg-3'>※</LineGlyph>
173
175
  <span className='min-w-0 flex-1 text-label leading-5 text-fg-3'>
174
176
  <span className='text-fg-2'>recap:</span> {text}
175
177
  </span>
@@ -258,9 +260,8 @@ function SentAttachments({
258
260
  attachments: MessageAttachment[]
259
261
  attachmentUrl?: (attachmentId: string) => string
260
262
  }) {
261
- const lines = useLines()
262
263
  return (
263
- <div className={cn('mb-1 flex flex-wrap gap-1.5', lines ? 'justify-start' : 'justify-end')}>
264
+ <div className='mb-1 flex flex-wrap justify-start gap-1.5'>
264
265
  {attachments.map((attachment) => {
265
266
  const href = attachmentUrl?.(attachment.id)
266
267
  return attachment.mediaType.startsWith('image/') && href ? (
@@ -339,6 +340,7 @@ function TranscriptRows({
339
340
  boundary,
340
341
  since,
341
342
  lines,
343
+ gap,
342
344
  fileUrl,
343
345
  attachmentUrl,
344
346
  hostImage,
@@ -348,6 +350,8 @@ function TranscriptRows({
348
350
  boundary: number | undefined
349
351
  since: number | undefined
350
352
  lines: boolean
353
+ /** The inter-row gap for this variant and density (`ROW_GAP`). */
354
+ gap: { className?: string; px: number }
351
355
  fileUrl?: (path: string) => string
352
356
  attachmentUrl?: (attachmentId: string) => string
353
357
  hostImage?: (path: string) => Promise<string | undefined>
@@ -387,7 +391,10 @@ function TranscriptRows({
387
391
  // a measurement replaces them the moment a row mounts. A lines row is one
388
392
  // text line more often than not; cards vary too much for any constant to
389
393
  // be right, so that one is merely the order of magnitude.
390
- estimateSize: () => (lines ? 32 : 100),
394
+ // Plus the gap, which is real height on the same measured element — an
395
+ // estimate that ignored it would make the scrollbar visibly too short on a
396
+ // long transcript before the rows mount.
397
+ estimateSize: () => (lines ? 32 : 100) + gap.px,
391
398
  overscan: 8,
392
399
  getItemKey: (index) => rows[index].key,
393
400
  // Explicit, and left at the default, because the obvious cleanup here is
@@ -484,13 +491,14 @@ function TranscriptRows({
484
491
  data-index={virtualRow.index}
485
492
  className={cn(
486
493
  'absolute inset-x-0 top-0',
487
- // The card layout's inter-row gap, folded into each row so the
488
- // measured height carries it: flex `gap` cannot reach absolutely
489
- // positioned rows, and a pixel constant for the virtualizer's
490
- // `gap` option would drift from the rem the layout is set in.
491
- // On this outer wrapper, not the row div, so a nested row's left
492
- // border still breaks across the gap as it did under flex.
493
- !lines && virtualRow.index > 0 && 'pt-4',
494
+ // The inter-row gap, folded into each row so the measured height
495
+ // carries it: flex `gap` cannot reach absolutely positioned rows,
496
+ // and a pixel constant for the virtualizer's `gap` option would
497
+ // drift from the rem the layout is set in. On this outer wrapper,
498
+ // not the row div, so a nested row's left border still breaks
499
+ // across the gap as it did under flex. Skipped for the first row —
500
+ // a gap above it would be padding, not spacing.
501
+ virtualRow.index > 0 && gap.className,
494
502
  )}
495
503
  style={{ transform: `translateY(${virtualRow.start}px)` }}>
496
504
  {'item' in row ? (
@@ -541,6 +549,12 @@ export interface TranscriptProps {
541
549
  * vertical space is scarce. See {@link TranscriptVariant}.
542
550
  */
543
551
  variant?: TranscriptVariant
552
+ /**
553
+ * How much air each row gets: `comfortable` (default — a blank line between
554
+ * messages, as the Claude Code CLI does) or `compact`. Independent of
555
+ * {@link TranscriptVariant}. See {@link TranscriptDensity}.
556
+ */
557
+ density?: TranscriptDensity
544
558
  /**
545
559
  * Catch-up: `from` is how many items had been seen last time, `since` when
546
560
  * that was. A recap row is drawn at that boundary and everything above it is
@@ -566,11 +580,13 @@ export function Transcript({
566
580
  canBrowseFiles,
567
581
  hostImage,
568
582
  variant = 'cards',
583
+ density = 'comfortable',
569
584
  catchUp,
570
585
  jumpToRecapRef,
571
586
  className,
572
587
  }: TranscriptProps) {
573
588
  const lines = variant === 'lines'
589
+ const gap = ROW_GAP[variant][density]
574
590
  const runStartedAt = useRunStart(state.status)
575
591
  const following = useSettled(state.items.length, state.status)
576
592
  // A boundary at (or past) the end means nothing is new — no row, no dimming.
@@ -605,6 +621,7 @@ export function Transcript({
605
621
  boundary={boundary}
606
622
  since={catchUp?.since}
607
623
  lines={lines}
624
+ gap={gap}
608
625
  fileUrl={fileUrl}
609
626
  attachmentUrl={attachmentUrl}
610
627
  hostImage={hostImage}
@@ -128,7 +128,7 @@ export function LineOptionList({
128
128
  'flex w-full items-baseline gap-2 text-left outline-none',
129
129
  isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
130
130
  )}>
131
- <LineGlyph className={isFocused ? 'text-accent' : undefined}>
131
+ <LineGlyph className={isFocused ? 'text-fg-1' : undefined}>
132
132
  {isFocused ? '❯' : ' '}
133
133
  </LineGlyph>
134
134
  <span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
@@ -184,7 +184,7 @@ export function LineInput({
184
184
  }) {
185
185
  return (
186
186
  <div className='flex items-baseline gap-2'>
187
- <LineGlyph className='text-accent'>›</LineGlyph>
187
+ <LineGlyph className='text-fg-3'>›</LineGlyph>
188
188
  <input
189
189
  autoFocus
190
190
  value={value}
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * The brand mark's pulse, as characters — the working marker every surface in the
5
+ * transcript animates.
6
+ *
7
+ * These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
8
+ * state"): a dot, an outline, a semi and a full diamond, built in the SVG from
9
+ * two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
10
+ * clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
11
+ * medium a transcript row actually has.
12
+ *
13
+ * BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
14
+ * *ambiguous width*, so they can render double-width in a terminal under an
15
+ * East-Asian locale and shift every line with them. They are safe wherever the
16
+ * glyph is centred in a fixed-width box, which is what `LineGlyph` is. Anything
17
+ * writing to a real terminal must use the ASCII set instead.
18
+ */
19
+ export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
20
+ export const PULSE_MS = 150
21
+
22
+ /**
23
+ * The resting state. Stopping the animation lands on the complete mark rather
24
+ * than on a half-drawn frame — the same property that makes the SVG's
25
+ * `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
26
+ */
27
+ export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
28
+
29
+ /** The OS-level "stop moving things" setting. A spinner is decoration — the word
30
+ * beside it carries the meaning — so honouring this costs nothing. */
31
+ export function usePrefersReducedMotion(): boolean {
32
+ const [reduced, setReduced] = useState(false)
33
+ useEffect(() => {
34
+ const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
35
+ if (!query) return
36
+ setReduced(query.matches)
37
+ const onChange = () => setReduced(query.matches)
38
+ query.addEventListener('change', onChange)
39
+ return () => query.removeEventListener('change', onChange)
40
+ }, [])
41
+ return reduced
42
+ }
43
+
44
+ /**
45
+ * The current pulse frame, ticking while `animated`. Callers mount this only
46
+ * while something is actually in flight, so nothing here runs on an idle
47
+ * session; with reduced motion, or when not animating, it holds at rest.
48
+ */
49
+ export function usePulse(animated: boolean): string {
50
+ const reduced = usePrefersReducedMotion()
51
+ const running = animated && !reduced
52
+ const [frame, setFrame] = useState(0)
53
+ useEffect(() => {
54
+ if (!running) return
55
+ const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
56
+ return () => clearInterval(timer)
57
+ }, [running])
58
+ if (!running) return PULSE_REST
59
+ return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
60
+ }
@@ -39,6 +39,68 @@ export function useLines(): boolean {
39
39
  return useTranscriptVariant() === 'lines'
40
40
  }
41
41
 
42
+ /**
43
+ * How much room the transcript gives each row.
44
+ *
45
+ * - `comfortable` — a blank line between messages, which is what the Claude Code
46
+ * CLI does and what the `lines` variant is trying to read like. The default:
47
+ * a transcript is prose before it is a table.
48
+ * - `compact` — rows tight against each other, for a dock where every line of
49
+ * vertical space is contested.
50
+ *
51
+ * Separate from the variant, and deliberately: they answer different questions.
52
+ * The variant decides *how a row is drawn* (boxed or not) and follows from the
53
+ * surface; density decides *how much air is around it* and is a preference the
54
+ * reader holds. Coupling them would mean a dock could not be roomy and a
55
+ * dashboard could not be dense.
56
+ */
57
+ export type TranscriptDensity = 'comfortable' | 'compact'
58
+
59
+ const DensityContext = createContext<TranscriptDensity>('comfortable')
60
+
61
+ export function TranscriptDensityProvider({
62
+ value,
63
+ children,
64
+ }: {
65
+ value: TranscriptDensity
66
+ children: ReactNode
67
+ }) {
68
+ return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
69
+ }
70
+
71
+ export function useTranscriptDensity(): TranscriptDensity {
72
+ return useContext(DensityContext)
73
+ }
74
+
75
+ /**
76
+ * The gap between two rows, per variant and density — the whole of the density
77
+ * feature, since it is the only vertical spacing between rows that exists.
78
+ *
79
+ * `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
80
+ * part of each row's measured height and no pixel constant is load-bearing.
81
+ * `px` is fed to `estimateSize` alone, where being approximate is the contract:
82
+ * it sets the scrollbar's length before rows mount and is replaced by a real
83
+ * measurement the moment one does.
84
+ *
85
+ * `lines` + `compact` is the only combination with no gap at all: there the
86
+ * row's own `py-0.5` is the entire separation, which is what makes it compact.
87
+ */
88
+ export const ROW_GAP: Record<
89
+ TranscriptVariant,
90
+ Record<TranscriptDensity, { className?: string; px: number }>
91
+ > = {
92
+ cards: {
93
+ comfortable: { className: 'pt-4', px: 16 },
94
+ compact: { className: 'pt-2', px: 8 },
95
+ },
96
+ lines: {
97
+ // 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
98
+ // line the CLI leaves, arrived at from the line height rather than picked.
99
+ comfortable: { className: 'pt-4', px: 16 },
100
+ compact: { px: 0 },
101
+ },
102
+ }
103
+
42
104
  /**
43
105
  * The left gutter of a line item: one glyph, fixed width, so every row's text
44
106
  * starts on the same column no matter which kind of event it is. Decorative —
@@ -14,7 +14,7 @@ export const MenuContent: FunctionComponent<
14
14
  align={align}
15
15
  side={side}
16
16
  sideOffset={sideOffset}
17
- className='isolate z-60 outline-none'>
17
+ className='isolate z-80 outline-none'>
18
18
  <MenuPrimitive.Popup
19
19
  data-slot='menu-content'
20
20
  className={cn(
@@ -45,7 +45,7 @@ export const SelectContent: FunctionComponent<
45
45
  alignItemWithTrigger={alignItemWithTrigger}
46
46
  side={side}
47
47
  sideOffset={sideOffset}
48
- className='isolate z-60 outline-none'>
48
+ className='isolate z-80 outline-none'>
49
49
  <SelectPrimitive.Popup
50
50
  data-slot='select-content'
51
51
  className={cn(
@@ -8,7 +8,7 @@ export const TooltipContent: FunctionComponent<
8
8
  TooltipPrimitive.Popup.Props & Pick<TooltipPrimitive.Positioner.Props, 'side' | 'sideOffset'>
9
9
  > = ({ className, side = 'top', sideOffset = 6, ...props }) => (
10
10
  <TooltipPrimitive.Portal>
11
- <TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className='isolate z-60'>
11
+ <TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className='isolate z-90'>
12
12
  <TooltipPrimitive.Popup
13
13
  data-slot='tooltip-content'
14
14
  className={cn(