@workerdeck/ui 0.9.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.
Files changed (65) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. package/src/workspace.ts +28 -0
@@ -1,29 +1,95 @@
1
+ import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'
2
+ import { useVirtualizer } from '@tanstack/react-virtual'
3
+ import { useStickToBottomContext } from 'use-stick-to-bottom'
1
4
  import type { MessageAttachment } from '@workerdeck/protocol'
2
- import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
5
+ import { recapLine, summarizeSince, type TranscriptItem, type TranscriptState } from '@workerdeck/react'
3
6
  import { cn } from '../../lib/utils.ts'
4
- import { formatCost, formatDuration } from '../../lib/format.ts'
7
+ import { formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
5
8
  import { Conversation, ConversationContent, ConversationScrollButton } from './Conversation.tsx'
6
9
  import { FileCard } from './FileCard.tsx'
7
10
  import { Loader } from './Loader.tsx'
8
11
  import { Message, MessageContent } from './Message.tsx'
12
+ import { PromptTokenText } from './PromptTokenText.tsx'
9
13
  import { Reasoning } from './Reasoning.tsx'
10
14
  import { Response } from './Response.tsx'
15
+ import { SessionEmptyState } from './SessionEmptyState.tsx'
11
16
  import { ToolCallCard } from './ToolCallCard.tsx'
17
+ import {
18
+ LineGlyph,
19
+ ROW_GAP,
20
+ TranscriptVariantProvider,
21
+ useLines,
22
+ type TranscriptDensity,
23
+ type TranscriptVariant,
24
+ } from './transcript-variant.tsx'
12
25
 
13
26
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
27
+ const lines = useLines()
28
+ if (lines) {
29
+ // One dim line, no rules: the turn's end is a footnote, not a divider that
30
+ // costs three rows of vertical space.
31
+ return (
32
+ <div data-slot='turn-result' className='flex items-baseline gap-2'>
33
+ <LineGlyph className='text-fg-4'>·</LineGlyph>
34
+ <div className='min-w-0 flex-1'>
35
+ <span className={cn('text-label leading-5', item.isError ? 'text-danger' : 'text-fg-4')}>
36
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
37
+ {formatCost(item.totalCostUsd)}
38
+ </span>
39
+ {item.errors?.length ? (
40
+ <ul className='flex flex-col'>
41
+ {item.errors.map((message, index) => (
42
+ <li key={index} className='text-label leading-5 break-words text-danger'>
43
+ {message}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ ) : null}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
14
52
  return (
15
- <div data-slot='turn-result' className='flex items-center gap-2 py-1'>
16
- <div className='h-px flex-1 bg-border' />
17
- <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
18
- {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
19
- {formatCost(item.totalCostUsd)}
20
- </span>
21
- <div className='h-px flex-1 bg-border' />
53
+ <div data-slot='turn-result' className='py-1'>
54
+ <div className='flex items-center gap-2'>
55
+ <div className='h-px flex-1 bg-border' />
56
+ <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
57
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
58
+ {formatCost(item.totalCostUsd)}
59
+ </span>
60
+ <div className='h-px flex-1 bg-border' />
61
+ </div>
62
+ {/* A failed turn's reasons are the whole point of the row — dropping them
63
+ leaves "error_during_execution" and nothing to act on. */}
64
+ {item.errors?.length ? (
65
+ <ul className='mt-1 flex flex-col gap-0.5 text-center'>
66
+ {item.errors.map((message, index) => (
67
+ <li key={index} className='text-label break-words text-danger'>
68
+ {message}
69
+ </li>
70
+ ))}
71
+ </ul>
72
+ ) : null}
22
73
  </div>
23
74
  )
24
75
  }
25
76
 
26
77
  function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
78
+ const lines = useLines()
79
+ if (lines) {
80
+ return (
81
+ <div data-slot='notice' className='flex items-baseline gap-2'>
82
+ <LineGlyph className={item.level === 'error' ? 'text-danger' : 'text-fg-4'}>!</LineGlyph>
83
+ <span
84
+ className={cn(
85
+ 'min-w-0 flex-1 text-body-sm leading-5',
86
+ item.level === 'error' ? 'text-danger' : 'text-fg-3',
87
+ )}>
88
+ {item.text}
89
+ </span>
90
+ </div>
91
+ )
92
+ }
27
93
  return (
28
94
  <div
29
95
  data-slot='notice'
@@ -42,10 +108,12 @@ function TranscriptItemView({
42
108
  item,
43
109
  fileUrl,
44
110
  attachmentUrl,
111
+ hostImage,
45
112
  }: {
46
113
  item: TranscriptItem
47
114
  fileUrl?: (path: string) => string
48
115
  attachmentUrl?: (attachmentId: string) => string
116
+ hostImage?: (path: string) => Promise<string | undefined>
49
117
  }) {
50
118
  switch (item.kind) {
51
119
  case 'user':
@@ -55,7 +123,11 @@ function TranscriptItemView({
55
123
  <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} />
56
124
  ) : null}
57
125
  {/* A photo can be the whole message — an empty bubble under it says nothing. */}
58
- {item.text ? <MessageContent>{item.text}</MessageContent> : null}
126
+ {item.text ? (
127
+ <MessageContent>
128
+ <PromptTokenText text={item.text} />
129
+ </MessageContent>
130
+ ) : null}
59
131
  </Message>
60
132
  )
61
133
  case 'assistant_text':
@@ -69,7 +141,7 @@ function TranscriptItemView({
69
141
  case 'thinking':
70
142
  return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
71
143
  case 'tool_call':
72
- return <ToolCallCard item={item} />
144
+ return <ToolCallCard item={item} hostImage={hostImage} />
73
145
  case 'turn_result':
74
146
  return <TurnResultRow item={item} />
75
147
  case 'notice':
@@ -81,6 +153,93 @@ function TranscriptItemView({
81
153
  }
82
154
  }
83
155
 
156
+ /**
157
+ * The "you were here" line: what happened since the session was last looked at,
158
+ * counted from the transcript rather than written by the model (see
159
+ * `summarizeSince`). Everything above it is dimmed while catch-up is on, so the
160
+ * boundary is visible from anywhere in the scrollback, not just at the mark.
161
+ *
162
+ * The line itself is computed in `Transcript`, not here: the recap is a row of
163
+ * the virtual list, and a boundary with nothing to say must contribute no row
164
+ * at all rather than an empty slot that still costs its gap.
165
+ */
166
+ function RecapRow({ line, since }: { line: string; since?: number }) {
167
+ const lines = useLines()
168
+ const away = since === undefined ? undefined : formatRelativeTime(since)
169
+ const text = away ? `${line} · last here ${away}` : line
170
+
171
+ if (lines) {
172
+ return (
173
+ <div data-slot='recap' className='flex items-baseline gap-2 py-0.5'>
174
+ <LineGlyph className='text-accent'>※</LineGlyph>
175
+ <span className='min-w-0 flex-1 text-label leading-5 text-fg-3'>
176
+ <span className='text-fg-2'>recap:</span> {text}
177
+ </span>
178
+ </div>
179
+ )
180
+ }
181
+ return (
182
+ <div data-slot='recap' className='flex items-center gap-2 py-1'>
183
+ <div className='h-px flex-1 bg-border' />
184
+ <span className='font-mono text-label text-fg-3'>※ recap: {text}</span>
185
+ <div className='h-px flex-1 bg-border' />
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Has the transcript stopped *filling* and started *streaming*?
192
+ *
193
+ * Attaching replays the whole session as a burst of events, so the content grows
194
+ * by hundreds of rows over a few hundred milliseconds. Animating that — which is
195
+ * the right behaviour for a live turn — turns opening a session into a
196
+ * several-second scroll from its first row to its last, which is precisely wrong
197
+ * when you are skimming sessions to see where each agent got to.
198
+ *
199
+ * So: instant until the arrivals stop for a beat, smooth from then on. Latched,
200
+ * because a live turn is bursty too and nobody wants the follow behaviour to
201
+ * flicker between modes mid-answer.
202
+ *
203
+ * Two guards, both load-bearing, both learned from this going wrong:
204
+ *
205
+ * - **Silence before the first row is not quiet, it is waiting.** The attach
206
+ * that fills this transcript is a round trip — and in a VS Code webview it is
207
+ * webview → extension host → gateway and back — so the first replayed row can
208
+ * land well after the quiet window. A timer started at mount then latches
209
+ * *before the transcript exists*, and the whole replay animates: exactly the
210
+ * symptom the latch was added to prevent.
211
+ * - **Only a live turn earns smooth.** An idle session has nothing to animate.
212
+ * Skimming finished sessions is therefore instant no matter what the timing
213
+ * did, which is the case that has to be right.
214
+ */
215
+ function useSettled(count: number, status: TranscriptState['status'], quietMs = 400): boolean {
216
+ const [settled, setSettled] = useState(false)
217
+ useEffect(() => {
218
+ if (settled || count === 0) return
219
+ const timer = setTimeout(() => setSettled(true), quietMs)
220
+ return () => clearTimeout(timer)
221
+ }, [count, settled, quietMs])
222
+ return settled && (status === 'running' || status === 'starting')
223
+ }
224
+
225
+ /**
226
+ * When the current run began — the clock the working line counts from.
227
+ *
228
+ * Taken here rather than in the loader because the loader comes and goes within
229
+ * a single turn (it hides the moment text starts streaming), and a clock that
230
+ * restarted every time the model paused for a tool would be measuring the wrong
231
+ * thing. Held as state, not a ref: the value has to survive re-renders and reset
232
+ * exactly once, when the session goes back to idle.
233
+ */
234
+ function useRunStart(status: TranscriptState['status']): number | undefined {
235
+ const running = status === 'running' || status === 'starting'
236
+ const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
237
+ useEffect(() => {
238
+ setStartedAt((previous) => (running ? (previous ?? Date.now()) : undefined))
239
+ }, [running])
240
+ return running ? startedAt : undefined
241
+ }
242
+
84
243
  /** Should the "waiting for output" loader show? Only while running with no in-flight
85
244
  * streamed content at the tail of the transcript. */
86
245
  function showLoader(state: TranscriptState): boolean {
@@ -101,8 +260,9 @@ function SentAttachments({
101
260
  attachments: MessageAttachment[]
102
261
  attachmentUrl?: (attachmentId: string) => string
103
262
  }) {
263
+ const lines = useLines()
104
264
  return (
105
- <div className='mb-1 flex flex-wrap justify-end gap-1.5'>
265
+ <div className={cn('mb-1 flex flex-wrap gap-1.5', lines ? 'justify-start' : 'justify-end')}>
106
266
  {attachments.map((attachment) => {
107
267
  const href = attachmentUrl?.(attachment.id)
108
268
  return attachment.mediaType.startsWith('image/') && href ? (
@@ -124,6 +284,251 @@ function SentAttachments({
124
284
  )
125
285
  }
126
286
 
287
+ /** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
288
+ * behind a rule, so a Task's own output reads as belonging to the tool call
289
+ * above it rather than as the main thread carrying on. */
290
+ function nestedClass(item: TranscriptItem, lines: boolean): string | undefined {
291
+ const nested = 'parentToolUseId' in item && item.parentToolUseId != null
292
+ if (!nested) return undefined
293
+ return lines ? 'ml-3.5 border-l border-border pl-2' : 'border-l-2 border-border pl-3'
294
+ }
295
+
296
+ /** One row of the virtual list: a transcript item, or the recap boundary line
297
+ * spliced in at `catchUp.from`. One flat array so the virtualizer sees stable
298
+ * indices, and each row carries the key the item was already React-keyed by —
299
+ * measurements are cached per key, so a row keeps its measured height when the
300
+ * recap splice shifts every index after it. */
301
+ type TranscriptRow =
302
+ | { key: string; item: TranscriptItem; index: number }
303
+ | { key: 'recap'; line: string }
304
+
305
+ /**
306
+ * The virtualized row window. Only rows near the viewport are mounted, so
307
+ * opening a thousand-row session commits a screenful of DOM rather than the
308
+ * whole session in one go.
309
+ *
310
+ * The delicate part is that two parties want to write `scrollTop`.
311
+ * `use-stick-to-bottom` owns *following*: its spring animates toward the
312
+ * bottom whenever the content grows, recomputing the target from the live
313
+ * `scrollHeight` every frame. The virtualizer wants to *correct* `scrollTop`
314
+ * whenever a row measures differently from its estimate, so that what the
315
+ * reader is looking at doesn't shift. Letting both write at once is jitter at
316
+ * best — and a correction that moves the viewport *up* (a row measuring
317
+ * smaller than estimated) reads as a user scroll to the follow logic, which
318
+ * escapes the bottom lock mid-stream.
319
+ *
320
+ * The resolution is that pinned and escaped are different regimes:
321
+ *
322
+ * - Pinned (`state.isAtBottom`), corrections are suppressed. "At the bottom"
323
+ * is the entire scroll position; the offsets of rows above are moot. The
324
+ * height change a mismeasured row causes still re-fires the follow spring —
325
+ * through the same content resize observer that follows streaming — so the
326
+ * view converges on the bottom through the one writer that knows how to
327
+ * distinguish its own writes from the user's.
328
+ * - Escaped, the virtualizer corrects (its stock rules, restated below) so
329
+ * the scrollback holds still under the reader while rows above it measure.
330
+ *
331
+ * `anchorTo`/`followOnAppend` stay at their defaults for the same reason:
332
+ * the virtualizer must never become a second follow implementation.
333
+ *
334
+ * Accepted costs of virtualizing, so they are a decision and not a surprise:
335
+ * browser find-in-page and select-all reach only the mounted rows, and a row's
336
+ * transient UI state (an expanded tool card, an opened reasoning block) resets
337
+ * once the row scrolls far enough away to unmount.
338
+ */
339
+ function TranscriptRows({
340
+ rows,
341
+ boundary,
342
+ since,
343
+ lines,
344
+ gap,
345
+ fileUrl,
346
+ attachmentUrl,
347
+ hostImage,
348
+ jumpToRecapRef,
349
+ }: {
350
+ rows: TranscriptRow[]
351
+ boundary: number | undefined
352
+ since: number | undefined
353
+ lines: boolean
354
+ /** The inter-row gap for this variant and density (`ROW_GAP`). */
355
+ gap: { className?: string; px: number }
356
+ fileUrl?: (path: string) => string
357
+ attachmentUrl?: (attachmentId: string) => string
358
+ hostImage?: (path: string) => Promise<string | undefined>
359
+ jumpToRecapRef?: RefObject<(() => void) | null>
360
+ }) {
361
+ const stick = useStickToBottomContext()
362
+ // The scroll element belongs to an ancestor — `StickToBottom.Content`
363
+ // renders it — so when this component's layout effects run at mount, the
364
+ // ancestor's ref is not attached yet and the virtualizer would see null.
365
+ // Handing it over from a passive effect (which runs after every ref in the
366
+ // commit is attached) also guarantees the re-render that lets the
367
+ // virtualizer adopt it promptly: without one, a transcript short enough to
368
+ // never fire a scroll event could sit renderless indefinitely.
369
+ const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
370
+ useEffect(() => {
371
+ setScrollElement(stick.scrollRef.current)
372
+ }, [stick.scrollRef])
373
+ const virtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
374
+ count: rows.length,
375
+ // On adopting a scroll element the virtualizer *replays* its remembered
376
+ // offset into the DOM (that is how `initialOffset` is honored). By then
377
+ // the pin has usually scrolled the element already, so the remembered 0
378
+ // would yank a pinned transcript silently back to the top — the observed
379
+ // race was exactly that, the follow jump at ~180ms and the replay undoing
380
+ // it at ~590ms. Syncing the remembered offset to the DOM at the adoption
381
+ // boundary makes every replay a no-op; from then on the scroll observer
382
+ // owns the field.
383
+ // Annotated because the body reads `virtualizer` back: without a declared
384
+ // return type the inference is circular and tsgo gives up on the whole hook.
385
+ getScrollElement: (): HTMLElement | null => {
386
+ if (scrollElement && virtualizer.scrollElement !== scrollElement) {
387
+ virtualizer.scrollOffset = scrollElement.scrollTop
388
+ }
389
+ return scrollElement
390
+ },
391
+ // Estimates only shape the scrollbar and the span of never-mounted rows;
392
+ // a measurement replaces them the moment a row mounts. A lines row is one
393
+ // text line more often than not; cards vary too much for any constant to
394
+ // be right, so that one is merely the order of magnitude.
395
+ // Plus the gap, which is real height on the same measured element — an
396
+ // estimate that ignored it would make the scrollbar visibly too short on a
397
+ // long transcript before the rows mount.
398
+ estimateSize: () => (lines ? 32 : 100) + gap.px,
399
+ overscan: 8,
400
+ getItemKey: (index) => rows[index].key,
401
+ // Explicit, and left at the default, because the obvious cleanup here is
402
+ // wrong. A correction fires from `measureElement`'s ref callback — inside
403
+ // React's commit — so the core's synchronous flush draws a "flushSync was
404
+ // called from inside a lifecycle method" error, which in an embedder's
405
+ // console reads as our bug. Turning it off silences that and costs
406
+ // anchoring: over the same six-step walk up through unmeasured rows, `true`
407
+ // holds the scrollback to the pixel and `false` let one step slide 112px
408
+ // under the reader. Holding still is the entire point of the correction,
409
+ // so the noise stays.
410
+ useFlushSync: true,
411
+ // The list sits below the content div's top padding, so row offsets are a
412
+ // few px shy of true scroll offsets. `scrollMargin` exists for exactly
413
+ // this, but feeding it means measuring the spacer's offsetTop into state;
414
+ // the error is smaller than one overscan row, so it is deliberately left.
415
+ })
416
+ // (See the component comment.) Supplying the callback at all replaces the
417
+ // core's default rules, so the escaped branch restates them: on a first
418
+ // measurement compensate any row whose top sits above the fold; on a
419
+ // re-measurement only a row entirely above it — a row *spanning* the fold
420
+ // grows below the reader's anchor point — and never while scrolling up,
421
+ // where corrections cascade.
422
+ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
423
+ if (stick.state.isAtBottom) return false
424
+ const fold = (instance.scrollOffset ?? 0) + instance.scrollAdjustments
425
+ return instance.itemSizeCache.has(item.key)
426
+ ? item.end <= fold && instance.scrollDirection !== 'backward'
427
+ : item.start < fold
428
+ }
429
+
430
+ // The catch-up strip's "jump" lives outside this scroll container, and the
431
+ // recap row it targets is usually unmounted — only the virtualizer knows
432
+ // where it would be, so the strip is handed this closure instead of a DOM
433
+ // query. Smooth on purpose: the jump IS a journey, and watching it travel
434
+ // is what tells you how far back the boundary was. `stopScroll()` first,
435
+ // because the pin spring is the other `scrollTop` writer and this is the
436
+ // library's own switch for "the user is leaving the bottom".
437
+ //
438
+ // And it has to *re-aim*. Every row between here and the boundary is
439
+ // unmeasured, so the offset the virtualizer scrolls to is the sum of a few
440
+ // hundred estimates; the real one only exists once those rows mount. A
441
+ // single smooth scroll therefore lands a screen or two short — measured at
442
+ // ~3300px off over 600 rows — and it cannot self-correct, because the core
443
+ // suppresses size-change adjustments outright while a smooth scroll is in
444
+ // flight. So: aim, let the rows it crossed measure, aim again from the
445
+ // better estimate. Each pass overshoots by less, and the moment the row is
446
+ // actually mounted the DOM can finish the job exactly.
447
+ //
448
+ // The pending re-aim lives in a ref, and this is the whole reason: the effect
449
+ // that publishes the closure has to re-run every render to keep `rows` fresh,
450
+ // so anything held in its scope is torn down every render too — and a jump in
451
+ // flight re-renders constantly, because that is what rows mounting *is*. A
452
+ // timer in the closure would be cancelled by the very work it is waiting for.
453
+ const aimTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
454
+ useEffect(() => () => clearTimeout(aimTimer.current), [])
455
+ useEffect(() => {
456
+ if (!jumpToRecapRef) return
457
+ jumpToRecapRef.current = () => {
458
+ const index = rows.findIndex((row) => row.key === 'recap')
459
+ if (index < 0) return
460
+ stick.stopScroll()
461
+ clearTimeout(aimTimer.current)
462
+ const aim = (attempt: number) => {
463
+ const row = scrollElement?.querySelector('[data-slot="recap"]')
464
+ if (row) {
465
+ row.scrollIntoView({ behavior: 'smooth', block: 'center' })
466
+ return
467
+ }
468
+ if (attempt >= 6) return
469
+ virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
470
+ // Longer than one frame: the aim is only better once the rows crossed
471
+ // have mounted *and* been measured, and that is a layout pass away.
472
+ aimTimer.current = setTimeout(() => aim(attempt + 1), 300)
473
+ }
474
+ aim(0)
475
+ }
476
+ return () => {
477
+ jumpToRecapRef.current = null
478
+ }
479
+ })
480
+
481
+ return (
482
+ <div
483
+ data-slot='transcript-rows'
484
+ className='relative w-full'
485
+ style={{ height: virtualizer.getTotalSize() }}>
486
+ {virtualizer.getVirtualItems().map((virtualRow) => {
487
+ const row = rows[virtualRow.index]
488
+ return (
489
+ <div
490
+ key={row.key}
491
+ ref={virtualizer.measureElement}
492
+ data-index={virtualRow.index}
493
+ className={cn(
494
+ 'absolute inset-x-0 top-0',
495
+ // The inter-row gap, folded into each row so the measured height
496
+ // carries it: flex `gap` cannot reach absolutely positioned rows,
497
+ // and a pixel constant for the virtualizer's `gap` option would
498
+ // drift from the rem the layout is set in. On this outer wrapper,
499
+ // not the row div, so a nested row's left border still breaks
500
+ // across the gap as it did under flex. Skipped for the first row —
501
+ // a gap above it would be padding, not spacing.
502
+ virtualRow.index > 0 && gap.className,
503
+ )}
504
+ style={{ transform: `translateY(${virtualRow.start}px)` }}>
505
+ {'item' in row ? (
506
+ <div
507
+ className={cn(
508
+ // Full-bleed hover: the row IS the affordance, so the
509
+ // highlight has to reach past the content gutter.
510
+ lines && '-mx-1 rounded-sm px-1 py-0.5 transition-colors hover:bg-surface-hover',
511
+ // Already read: present, legible, and visibly behind you.
512
+ boundary !== undefined && row.index < boundary && 'opacity-45',
513
+ nestedClass(row.item, lines),
514
+ )}>
515
+ <TranscriptItemView
516
+ item={row.item}
517
+ fileUrl={fileUrl}
518
+ attachmentUrl={attachmentUrl}
519
+ hostImage={hostImage}
520
+ />
521
+ </div>
522
+ ) : (
523
+ <RecapRow line={row.line} since={since} />
524
+ )}
525
+ </div>
526
+ )
527
+ })}
528
+ </div>
529
+ )
530
+ }
531
+
127
532
  export interface TranscriptProps {
128
533
  state: TranscriptState
129
534
  /** Builds the download URL for a delivered file (see FileCard). Typically
@@ -133,30 +538,108 @@ export interface TranscriptProps {
133
538
  * `(id) => client.attachmentUrl(sessionId, id)`. Same-origin and
134
539
  * cookie-authenticated, which is what lets an `<img src>` render one. */
135
540
  attachmentUrl?: (attachmentId: string) => string
541
+ /** Whether this gateway serves `@file` search here — the empty state must not
542
+ * advertise an affordance the composer doesn't have. */
543
+ canBrowseFiles?: boolean
544
+ /** Reads a host file as a data URL, for tool calls whose output is a picture
545
+ * on the host (codex's `image_gen`). Omit and those cards name the path. */
546
+ hostImage?: (path: string) => Promise<string | undefined>
547
+ /**
548
+ * How a turn is drawn: `cards` (default, the chat convention) or `lines` —
549
+ * full-width transparent line items with a gutter glyph, for hosts where
550
+ * vertical space is scarce. See {@link TranscriptVariant}.
551
+ */
552
+ variant?: TranscriptVariant
553
+ /**
554
+ * How much air each row gets: `comfortable` (default — a blank line between
555
+ * messages, as the Claude Code CLI does) or `compact`. Independent of
556
+ * {@link TranscriptVariant}. See {@link TranscriptDensity}.
557
+ */
558
+ density?: TranscriptDensity
559
+ /**
560
+ * Catch-up: `from` is how many items had been seen last time, `since` when
561
+ * that was. A recap row is drawn at that boundary and everything above it is
562
+ * dimmed. Omit (or pass a boundary at/after the end) and the transcript
563
+ * renders exactly as before.
564
+ */
565
+ catchUp?: { from: number; since?: number }
566
+ /**
567
+ * Filled with a closure that scrolls the recap row into view — the seam the
568
+ * panel's catch-up strip presses. A ref rather than a DOM query because the
569
+ * rows are virtualized: when the recap row isn't mounted, only the
570
+ * virtualizer knows where it would be. Optional; embedders without a
571
+ * catch-up strip never touch it. `null` while no transcript is mounted.
572
+ */
573
+ jumpToRecapRef?: RefObject<(() => void) | null>
136
574
  className?: string
137
575
  }
138
576
 
139
- export function Transcript({ state, fileUrl, attachmentUrl, className }: TranscriptProps) {
577
+ export function Transcript({
578
+ state,
579
+ fileUrl,
580
+ attachmentUrl,
581
+ canBrowseFiles,
582
+ hostImage,
583
+ variant = 'cards',
584
+ density = 'comfortable',
585
+ catchUp,
586
+ jumpToRecapRef,
587
+ className,
588
+ }: TranscriptProps) {
589
+ const lines = variant === 'lines'
590
+ const gap = ROW_GAP[variant][density]
591
+ const runStartedAt = useRunStart(state.status)
592
+ const following = useSettled(state.items.length, state.status)
593
+ // A boundary at (or past) the end means nothing is new — no row, no dimming.
594
+ const boundary =
595
+ catchUp && catchUp.from > 0 && catchUp.from < state.items.length ? catchUp.from : undefined
596
+ const recap = useMemo(
597
+ () => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))),
598
+ [state, boundary],
599
+ )
600
+ const rows = useMemo(() => {
601
+ const out: TranscriptRow[] = []
602
+ for (const [index, item] of state.items.entries()) {
603
+ if (index === boundary && recap) out.push({ key: 'recap', line: recap })
604
+ out.push({ key: `${item.kind}:${item.id}`, item, index })
605
+ }
606
+ return out
607
+ }, [state.items, boundary, recap])
140
608
  return (
141
- <Conversation className={className}>
142
- <ConversationContent>
143
- {state.items.length === 0 && state.status !== 'starting' ? (
144
- <div className='py-12 text-center text-body-sm text-fg-4'>No messages yet.</div>
145
- ) : (
146
- state.items.map((item) => (
147
- <TranscriptItemView
148
- key={`${item.kind}:${item.id}`}
149
- item={item}
609
+ <TranscriptVariantProvider value={variant}>
610
+ <Conversation className={className} resize={following ? 'smooth' : 'instant'}>
611
+ <ConversationContent className={cn(lines && 'gap-0 px-2 py-1.5')}>
612
+ {state.items.length === 0 && state.status !== 'starting' ? (
613
+ <SessionEmptyState
614
+ cwd={state.cwd}
615
+ hasCommands={!!state.commands?.length}
616
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
617
+ canBrowseFiles={canBrowseFiles}
618
+ />
619
+ ) : (
620
+ <TranscriptRows
621
+ rows={rows}
622
+ boundary={boundary}
623
+ since={catchUp?.since}
624
+ lines={lines}
625
+ gap={gap}
150
626
  fileUrl={fileUrl}
151
627
  attachmentUrl={attachmentUrl}
628
+ hostImage={hostImage}
629
+ jumpToRecapRef={jumpToRecapRef}
152
630
  />
153
- ))
154
- )}
155
- {showLoader(state) ? (
156
- <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
157
- ) : null}
158
- </ConversationContent>
159
- <ConversationScrollButton />
160
- </Conversation>
631
+ )}
632
+ {showLoader(state) ? (
633
+ <Loader
634
+ label={state.status === 'starting' ? 'Starting session…' : undefined}
635
+ startedAt={runStartedAt}
636
+ tokens={state.contextUsage?.totalTokens}
637
+ className={cn(lines && 'py-0.5')}
638
+ />
639
+ ) : null}
640
+ </ConversationContent>
641
+ <ConversationScrollButton />
642
+ </Conversation>
643
+ </TranscriptVariantProvider>
161
644
  )
162
645
  }