@workerdeck/ui 0.15.0 → 0.16.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 +53 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
  4. package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +442 -45
  9. package/build/index.mjs +105 -2
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionPanel.tsx +224 -28
  28. package/src/components/agent/SessionWorkspace.tsx +29 -0
  29. package/src/components/agent/StatusBar.tsx +20 -4
  30. package/src/components/agent/ToolCallCard.tsx +17 -111
  31. package/src/components/agent/Transcript.tsx +710 -203
  32. package/src/components/agent/UsageDialog.tsx +20 -106
  33. package/src/components/agent/UsageMeters.tsx +133 -0
  34. package/src/components/agent/pulse.tsx +3 -2
  35. package/src/components/agent/transcript-rows.ts +82 -0
  36. package/src/components/agent/transcript-variant.tsx +29 -51
  37. package/src/components/agent/use-height-epoch.ts +60 -0
  38. package/src/components/agent/use-path-links.ts +147 -0
  39. package/src/components/agent/use-transcript-jumps.ts +190 -0
  40. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  41. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  42. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  43. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  44. package/src/components/terminal/StatusLine.tsx +159 -0
  45. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  46. package/src/components/terminal/affordances.tsx +118 -0
  47. package/src/components/terminal/diff.tsx +130 -0
  48. package/src/components/terminal/height.ts +727 -0
  49. package/src/components/terminal/items.tsx +449 -0
  50. package/src/components/terminal/markdown.tsx +191 -0
  51. package/src/components/terminal/press.tsx +120 -0
  52. package/src/components/terminal/prompt.tsx +343 -0
  53. package/src/components/terminal/result-preview.ts +72 -0
  54. package/src/components/terminal/row.tsx +132 -0
  55. package/src/components/terminal/scrubber.tsx +663 -0
  56. package/src/components/terminal/surface.tsx +80 -0
  57. package/src/components/terminal/tool-run.ts +91 -0
  58. package/src/index.ts +34 -0
  59. package/src/lib/status.ts +59 -3
  60. package/src/lib/tool-icon.ts +14 -0
  61. package/src/styles/terminal.css +1011 -0
  62. package/src/styles/theme.css +41 -0
  63. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  64. package/build/format.mjs.map +0 -1
  65. package/src/components/agent/line-prompt.tsx +0 -249
@@ -1,7 +1,17 @@
1
- import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'
2
- import { useVirtualizer } from '@tanstack/react-virtual'
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ type RefObject,
10
+ } from 'react'
11
+ import { createPortal } from 'react-dom'
12
+ import { defaultRangeExtractor, useVirtualizer, type Range } from '@tanstack/react-virtual'
3
13
  import { useStickToBottomContext } from 'use-stick-to-bottom'
4
- import type { MessageAttachment } from '@workerdeck/protocol'
14
+ import type { MessageAttachment, PermissionRequest } from '@workerdeck/protocol'
5
15
  import { recapLine, summarizeSince, type TranscriptItem, type TranscriptState } from '@workerdeck/react'
6
16
  import { cn } from '../../lib/utils.ts'
7
17
  import { formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
@@ -14,41 +24,24 @@ import { Reasoning } from './Reasoning.tsx'
14
24
  import { Response } from './Response.tsx'
15
25
  import { SessionEmptyState } from './SessionEmptyState.tsx'
16
26
  import { ToolCallCard } from './ToolCallCard.tsx'
27
+ import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
28
+ import { ToolRunRow, WorkingRow, terminalBlocks } from '../terminal/items.tsx'
29
+ import { estimateBlockPx } from '../terminal/height.ts'
30
+ import { TerminalScrubber } from '../terminal/scrubber.tsx'
31
+ import { gapBefore, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
32
+ import { useHeightEpoch } from './use-height-epoch.ts'
33
+ import { useTranscriptJumps } from './use-transcript-jumps.ts'
34
+ import { Row } from '../terminal/row.tsx'
35
+ import { TerminalSurface } from '../terminal/surface.tsx'
36
+ import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
17
37
  import {
18
- LineGlyph,
19
38
  ROW_GAP,
20
39
  TranscriptVariantProvider,
21
- useLines,
22
40
  type TranscriptDensity,
23
41
  type TranscriptVariant,
24
42
  } from './transcript-variant.tsx'
25
43
 
26
44
  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
- }
52
45
  return (
53
46
  <div data-slot='turn-result' className='py-1'>
54
47
  <div className='flex items-center gap-2'>
@@ -75,21 +68,6 @@ function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_r
75
68
  }
76
69
 
77
70
  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
- }
93
71
  return (
94
72
  <div
95
73
  data-slot='notice'
@@ -109,12 +87,17 @@ function TranscriptItemView({
109
87
  fileUrl,
110
88
  attachmentUrl,
111
89
  hostImage,
90
+ terminal,
112
91
  }: {
113
92
  item: TranscriptItem
114
93
  fileUrl?: (path: string) => string
115
94
  attachmentUrl?: (attachmentId: string) => string
116
95
  hostImage?: (path: string) => Promise<string | undefined>
96
+ terminal?: boolean
117
97
  }) {
98
+ // The terminal theme is a renderer, not a branch: it draws every kind itself,
99
+ // so the switch below is never reached under it.
100
+ if (terminal) return <TerminalItemView item={item} fileUrl={fileUrl} />
118
101
  switch (item.kind) {
119
102
  case 'user':
120
103
  return (
@@ -163,21 +146,27 @@ function TranscriptItemView({
163
146
  * the virtual list, and a boundary with nothing to say must contribute no row
164
147
  * at all rather than an empty slot that still costs its gap.
165
148
  */
166
- function RecapRow({ line, since }: { line: string; since?: number }) {
167
- const lines = useLines()
149
+ function RecapRow({ line, since, terminal }: { line: string; since?: number; terminal?: boolean }) {
168
150
  const away = since === undefined ? undefined : formatRelativeTime(since)
169
151
  const text = away ? `${line} · last here ${away}` : line
170
152
 
171
- if (lines) {
153
+ // Under the terminal theme it is a Row like everything else, and that is not
154
+ // cosmetic: the cards markup measures 42px against an 18px line, so it was the
155
+ // one row in the transcript sitting off the grid — and it shifted *every row
156
+ // below it* by the remainder, which is precisely the failure the whole-multiple
157
+ // rule exists to prevent. It was invisible until a fixture carried a recap
158
+ // splice. Being a real row also makes it exactly computable, so the height
159
+ // calculator loses its last estimated constant.
160
+ if (terminal) {
172
161
  return (
173
- <div data-slot='recap' className='flex items-baseline gap-2 py-0.5'>
174
- <LineGlyph className='text-fg-3'>※</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>
162
+ <div data-slot='recap'>
163
+ <Row glyph='' glyphTone='faint' tone='faint'>
164
+ recap: {text}
165
+ </Row>
178
166
  </div>
179
167
  )
180
168
  }
169
+
181
170
  return (
182
171
  <div data-slot='recap' className='flex items-center gap-2 py-1'>
183
172
  <div className='h-px flex-1 bg-border' />
@@ -188,39 +177,24 @@ function RecapRow({ line, since }: { line: string; since?: number }) {
188
177
  }
189
178
 
190
179
  /**
191
- * Has the transcript stopped *filling* and started *streaming*?
180
+ * **Nothing on this surface animates its scroll position.** VS Code does not —
181
+ * click its editor scrollbar and it jumps — and neither does a terminal, which
182
+ * is the article this transcript is drawing. Every travel a reader ever
183
+ * complained about here was an animation we asked for.
192
184
  *
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 turnturns 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.
185
+ * What used to be here was `useSettled`: a latch deciding smooth-vs-instant for
186
+ * the follow spring, with a quiet window, a "silence before the first row does
187
+ * not count" guard and a live-status gateall of it apparatus for a
188
+ * smooth-scroll bug (the attach replays hundreds of rows, and animating that
189
+ * turned opening a session into a several-second journey). With no smooth mode
190
+ * left there is nothing for it to decide, so the whole thing is gone rather
191
+ * than pinned to `false`.
198
192
  *
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.
193
+ * The two remaining writers of `scrollTop` the follow spring and the
194
+ * virtualizer's size-change correction are unchanged and still split by
195
+ * regime; `Conversation` itself is now hardwired to `instant` on both `initial`
196
+ * and `resize`.
214
197
  */
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
198
 
225
199
  /**
226
200
  * When the current run began — the clock the working line counts from.
@@ -283,23 +257,137 @@ function SentAttachments({
283
257
  )
284
258
  }
285
259
 
260
+ /**
261
+ * The terminal theme's root, when that is the variant, and a passthrough when it
262
+ * is not.
263
+ *
264
+ * It has to live *inside* the scroller's content element rather than around the
265
+ * whole `Conversation`: `--term-line` and `1ch` are inherited, and the rows are
266
+ * rendered by the virtualizer several levels down. Wrapping the scroller instead
267
+ * would work identically for the cells and break the full-bleed bands, whose
268
+ * negative margins are measured against this element's padding.
269
+ */
270
+ function TerminalShell({
271
+ active,
272
+ fontSize,
273
+ lineHeight,
274
+ affordances,
275
+ children,
276
+ }: {
277
+ active: boolean
278
+ fontSize?: number
279
+ lineHeight?: number
280
+ affordances?: TerminalAffordances | boolean
281
+ children: ReactNode
282
+ }) {
283
+ if (!active) return <>{children}</>
284
+ return (
285
+ <TerminalSurface
286
+ fontSize={fontSize}
287
+ lineHeight={lineHeight}
288
+ affordances={affordances}
289
+ bleed='1ch'
290
+ className='term-transcript'>
291
+ {children}
292
+ </TerminalSurface>
293
+ )
294
+ }
295
+
296
+ /** Already read: present, legible, and visibly behind you. */
297
+ function read(boundary: number | undefined, index: number): boolean {
298
+ return boundary !== undefined && index < boundary
299
+ }
300
+
286
301
  /** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
287
302
  * behind a rule, so a Task's own output reads as belonging to the tool call
288
303
  * above it rather than as the main thread carrying on. */
289
- function nestedClass(item: TranscriptItem, lines: boolean): string | undefined {
304
+ function nestedClass(item: TranscriptItem): string | undefined {
290
305
  const nested = 'parentToolUseId' in item && item.parentToolUseId != null
291
- if (!nested) return undefined
292
- return lines ? 'ml-3.5 border-l border-border pl-2' : 'border-l-2 border-border pl-3'
306
+ return nested ? 'border-l-2 border-border pl-3' : undefined
293
307
  }
294
308
 
295
- /** One row of the virtual list: a transcript item, or the recap boundary line
296
- * spliced in at `catchUp.from`. One flat array so the virtualizer sees stable
297
- * indices, and each row carries the key the item was already React-keyed by —
298
- * measurements are cached per key, so a row keeps its measured height when the
299
- * recap splice shifts every index after it. */
300
- type TranscriptRow =
301
- | { key: string; item: TranscriptItem; index: number }
302
- | { key: 'recap'; line: string }
309
+ // The virtual row model what a row *is*, the spacing rule, and the
310
+ // item-index row-index mapping lives in `transcript-rows.ts`; re-exported
311
+ // here because this file is where consumers have always found them.
312
+ export { rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
313
+
314
+ /**
315
+ * A prompt row's sticky lane the strip spanning its turn, leading with the
316
+ * one-line pinned **head** (see the pinned-prompt comment in
317
+ * {@link TranscriptRows}).
318
+ *
319
+ * The head starts `visibility: hidden` and shows only while actually stuck —
320
+ * an overlay that is visible in flow would sit on the real row's first line
321
+ * and swallow its selection highlight, which reads as "the first line cannot
322
+ * be selected". CSS cannot ask "am I stuck?", so a 1px sentinel at the head's
323
+ * engage threshold (the line's own y) feeds an IntersectionObserver: sentinel
324
+ * above the scrollport top → stuck. Transition-only callbacks — this adds no
325
+ * per-scroll work, and the pin itself is still the compositor's.
326
+ */
327
+ function StickyPromptLane({
328
+ top,
329
+ height,
330
+ gapClass,
331
+ scrollRoot,
332
+ index,
333
+ measureRef,
334
+ content,
335
+ }: {
336
+ top: number
337
+ height: number
338
+ gapClass?: string | false
339
+ scrollRoot: HTMLElement | null
340
+ index: number
341
+ measureRef: (element: HTMLDivElement | null) => void
342
+ content: ReactNode
343
+ }) {
344
+ const headRef = useRef<HTMLDivElement | null>(null)
345
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
346
+ useEffect(() => {
347
+ const head = headRef.current
348
+ const sentinel = sentinelRef.current
349
+ if (!head || !sentinel || !scrollRoot) return
350
+ const observer = new IntersectionObserver(
351
+ ([entry]) => {
352
+ if (!entry) return
353
+ // Above the scrollport, not merely out of it — a lane still below the
354
+ // viewport has its sentinel non-intersecting too.
355
+ const stuck =
356
+ !entry.isIntersecting &&
357
+ entry.boundingClientRect.top < (entry.rootBounds?.top ?? 0)
358
+ head.toggleAttribute('data-stuck', stuck)
359
+ },
360
+ { root: scrollRoot },
361
+ )
362
+ observer.observe(sentinel)
363
+ return () => observer.disconnect()
364
+ }, [scrollRoot])
365
+ return (
366
+ <div data-sticky-lane='' className='absolute inset-x-0' style={{ top, height }}>
367
+ {/* The head rides in its own absolutely positioned sub-lane rather than
368
+ in flow with a cancelled footprint: sticky confinement clamps the
369
+ *margin* box, and a negative bottom margin shrinks that box to zero
370
+ height — the head then overshoots the lane's end by its own height,
371
+ which put two pinned prompts on screen at once during the handoff.
372
+ Out of flow, the border box is what gets clamped, and the push-off
373
+ lands exactly at the lane's bottom edge. */}
374
+ <div data-sticky-headlane='' aria-hidden>
375
+ <div ref={headRef} data-sticky-head='' className={gapClass || undefined}>
376
+ {content}
377
+ </div>
378
+ </div>
379
+ <div
380
+ ref={sentinelRef}
381
+ aria-hidden
382
+ className='absolute left-0 w-px'
383
+ style={{ top: gapClass ? 'var(--term-line)' : 0, height: 1 }}
384
+ />
385
+ <div ref={measureRef} data-index={index} className={gapClass || undefined}>
386
+ {content}
387
+ </div>
388
+ </div>
389
+ )
390
+ }
303
391
 
304
392
  /**
305
393
  * The virtualized row window. Only rows near the viewport are mounted, so
@@ -339,23 +427,52 @@ function TranscriptRows({
339
427
  rows,
340
428
  boundary,
341
429
  since,
342
- lines,
430
+ terminal,
431
+ replaying,
432
+ stickyPrompt,
343
433
  gap,
434
+ fontSize,
435
+ lineHeight,
436
+ items,
437
+ pendingApprovals,
438
+ scrubber,
439
+ scrubberMarks,
440
+ affordances,
344
441
  fileUrl,
345
442
  attachmentUrl,
346
443
  hostImage,
347
444
  jumpToRecapRef,
445
+ repinRef,
348
446
  }: {
349
447
  rows: TranscriptRow[]
350
448
  boundary: number | undefined
351
449
  since: number | undefined
352
- lines: boolean
450
+ /** The terminal theme draws its own rows; see {@link TranscriptItemView}. */
451
+ terminal: boolean
452
+ /** The replay hold (see {@link TranscriptProps.replaying}) — read here only
453
+ * for its falling edge, which needs a pre-paint pin. */
454
+ replaying: boolean
455
+ /** Pin the prompt of the turn being read to the top of the scroller. */
456
+ stickyPrompt: boolean
353
457
  /** The inter-row gap for this variant and density (`ROW_GAP`). */
354
458
  gap: { className?: string; px: number }
459
+ /** The terminal cell, when the host set one — only read as a signal that the
460
+ * height epoch below must re-measure; the epoch's numbers come from the DOM. */
461
+ fontSize?: number
462
+ lineHeight?: number
463
+ /** The transcript items — the scrubber's marks and peeks render from these,
464
+ * never from the DOM (the row a mark points at is usually unmounted). */
465
+ items: readonly TranscriptItem[]
466
+ pendingApprovals: readonly PermissionRequest[]
467
+ /** Mount the overview-ruler rail (terminal theme only). */
468
+ scrubber?: boolean
469
+ scrubberMarks?: readonly number[]
470
+ affordances?: TerminalAffordances | boolean
355
471
  fileUrl?: (path: string) => string
356
472
  attachmentUrl?: (attachmentId: string) => string
357
473
  hostImage?: (path: string) => Promise<string | undefined>
358
474
  jumpToRecapRef?: RefObject<(() => void) | null>
475
+ repinRef?: RefObject<(() => void) | null>
359
476
  }) {
360
477
  const stick = useStickToBottomContext()
361
478
  // The scroll element belongs to an ancestor — `StickToBottom.Content`
@@ -366,9 +483,90 @@ function TranscriptRows({
366
483
  // virtualizer adopt it promptly: without one, a transcript short enough to
367
484
  // never fire a scroll event could sit renderless indefinitely.
368
485
  const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
486
+ // Which rows *could* be the pinned prompt. Recomputed only when the row list
487
+ // changes, so the per-scroll work below is a walk over prompts rather than
488
+ // over the transcript.
489
+ const promptRows = useMemo(
490
+ () => rows.flatMap((row, index) => ('item' in row && row.item.kind === 'user' ? [index] : [])),
491
+ [rows],
492
+ )
493
+ // The pinned row must stay mounted even when it is far above the window, so
494
+ // it is forced into the virtual range — see `rangeExtractor` below, which
495
+ // reads these refs rather than closing over render values: it is called from
496
+ // inside the virtualizer's own range pass, where a closure would be a stale
497
+ // render's.
498
+ const pinRef = useRef<{ enabled: boolean; promptRows: readonly number[] }>({
499
+ enabled: false,
500
+ promptRows: [],
501
+ })
502
+ pinRef.current = { enabled: terminal && stickyPrompt, promptRows }
369
503
  useEffect(() => {
370
504
  setScrollElement(stick.scrollRef.current)
371
505
  }, [stick.scrollRef])
506
+
507
+ // A composer that grows steals a line from the transcript.
508
+ //
509
+ // The composer and the transcript are siblings in the panel's flex column, so
510
+ // typing a newline shrinks this scroller by one line. "At the bottom" is a
511
+ // `scrollTop`, and that number stops meaning the bottom the moment the
512
+ // viewport changes height — so the last row, the one you were reading, slides
513
+ // under the fold as you type.
514
+ //
515
+ // `use-stick-to-bottom` cannot catch this: its ResizeObserver observes the
516
+ // **content** element (`.observe(content)` in its `useStickToBottom`), and
517
+ // here the content is unchanged and the *scroller* moved. Nor does the browser
518
+ // help — scrollTop is untouched, so no scroll event fires and nothing
519
+ // recomputes. Hence an observer of our own, on the scroller's own box.
520
+ //
521
+ // The guard is the whole feature: re-pin **only when already pinned**, or
522
+ // every newline yanks a reader who had deliberately scrolled up. That state
523
+ // lives in here, which is why this is in `Transcript` and not `SessionPanel`.
524
+ // Reading `stick.state.isAtBottom` (the library's live object, not the
525
+ // rendered boolean) is safe precisely because of the paragraph above: no
526
+ // scroll event fired, so the flag still holds the pre-resize answer.
527
+ //
528
+ // This is not a third writer of `scrollTop` — it presses the follow spring's
529
+ // own button, instantly, which is what the spring would have done had it
530
+ // noticed. The pinned-suppresses-corrections regime below is untouched.
531
+ useEffect(() => {
532
+ if (!scrollElement) return
533
+ let last = scrollElement.clientHeight
534
+ const observer = new ResizeObserver(() => {
535
+ const height = scrollElement.clientHeight
536
+ if (height === last) return
537
+ last = height
538
+ if (stick.state.isAtBottom) void stick.scrollToBottom('instant')
539
+ })
540
+ observer.observe(scrollElement)
541
+ return () => observer.disconnect()
542
+ }, [scrollElement, stick])
543
+
544
+ // The replay hold's reveal must paint already at the bottom, and the follow
545
+ // spring cannot make that true: even `scrollToBottom('instant')` defers its
546
+ // write behind a `requestAnimationFrame`, one frame after the reveal's paint
547
+ // — so the first visible frame showed the tail a burst shy of the bottom and
548
+ // then hopped (measured: revealTop 33037 against final 34459 on the 600-row
549
+ // fixture). A layout effect runs after the commit that removed the hold's
550
+ // visibility and before its paint, so this write lands in the very frame the
551
+ // transcript appears. It presses the library's own `state.scrollTop` setter
552
+ // — which records the write in `ignoreScrollToTop`, so the scroll handler
553
+ // knows it for its own — not a raw `scrollTop`, and only on the hold's
554
+ // falling edge, only while pinned; it is the pin's own move made a frame
555
+ // early, not a third writer.
556
+ const wasReplaying = useRef(replaying)
557
+ useLayoutEffect(() => {
558
+ const was = wasReplaying.current
559
+ wasReplaying.current = replaying
560
+ if (!was || replaying) return
561
+ if (!stick.state.isAtBottom) return
562
+ stick.state.scrollTop = stick.state.calculatedTargetScrollTop
563
+ }, [replaying, stick])
564
+
565
+ // The height epoch — see `use-height-epoch.ts`. Owned here because this
566
+ // component owns the virtualizer the heights feed.
567
+ const rowsRef = useRef<HTMLDivElement | null>(null)
568
+ const epoch = useHeightEpoch({ terminal, fontSize, lineHeight, rowsRef })
569
+
372
570
  const virtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
373
571
  count: rows.length,
374
572
  // On adopting a scroll element the virtualizer *replays* its remembered
@@ -387,16 +585,56 @@ function TranscriptRows({
387
585
  }
388
586
  return scrollElement
389
587
  },
390
- // Estimates only shape the scrollbar and the span of never-mounted rows;
391
- // a measurement replaces them the moment a row mounts. A lines row is one
392
- // text line more often than not; cards vary too much for any constant to
393
- // be right, so that one is merely the order of magnitude.
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,
588
+ // Estimates only shape the scrollbar and the span of never-mounted rows; a
589
+ // measurement replaces them the moment a row mounts. Under the terminal
590
+ // theme they are *computed* (`terminal/height.ts`): the theme's one line
591
+ // height and one cell make a row's height derivable from its item, so the
592
+ // scrollbar is honest before rows mount and `scrollToIndex` sums real
593
+ // sizes instead of accumulating error over unmeasured spans. The gap rides
594
+ // the same estimate because it is real height on the same measured element
595
+ // one line, decided per pair by `gapBefore`, exactly as the renderer
596
+ // applies the class. The recap row is a Row here too, so it is one line and
597
+ // exact — it used to be the cards markup, which measured 42px against an
598
+ // 18px line and pushed every row below it off the grid.
599
+ // Cards keep the flat constant: they vary too much for any constant to be
600
+ // right, so it is merely the order of magnitude — and the calculator has
601
+ // no claim there (padding scales, borders, a proportional face).
602
+ estimateSize: (index) => {
603
+ if (terminal && epoch) {
604
+ const row = rows[index]
605
+ const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
606
+ if (row && ('item' in row || 'run' in row))
607
+ return estimateBlockPx(row, epoch) + gapPx
608
+ return epoch.line + gapPx // recap: one Row, one line
609
+ }
610
+ return (terminal ? 36 : 100) + gap.px
611
+ },
398
612
  overscan: 8,
399
613
  getItemKey: (index) => rows[index].key,
614
+ // The sticky row, forced into the range. This is the virtualizer's own
615
+ // sticky-header seam: without it the pinned prompt's lane unmounts the
616
+ // moment it leaves the window, which is exactly when it is doing its job.
617
+ // The active prompt — the last one starting at or above the fold — is
618
+ // computed *here*, from the instance's own offset, rather than in the
619
+ // render body: the range pass runs before the render that would refresh a
620
+ // ref, so a value computed outside this callback is one scroll event
621
+ // stale, and a long programmatic jump would leave the pinned row unmounted
622
+ // until the next scroll. (`virtualizer` is safe to close over: the hook
623
+ // returns one stable instance for the component's lifetime.)
624
+ rangeExtractor: useCallback((range: Range) => {
625
+ const indexes = new Set(defaultRangeExtractor(range))
626
+ const { enabled, promptRows: prompts } = pinRef.current
627
+ if (enabled) {
628
+ const offset = virtualizer.scrollOffset ?? 0
629
+ let pinned = -1
630
+ for (const index of prompts) {
631
+ if ((virtualizer.measurementsCache[index]?.start ?? Infinity) <= offset) pinned = index
632
+ else break
633
+ }
634
+ if (pinned >= 0) indexes.add(pinned)
635
+ }
636
+ return [...indexes].sort((a, b) => a - b)
637
+ }, []),
400
638
  // Explicit, and left at the default, because the obvious cleanup here is
401
639
  // wrong. A correction fires from `measureElement`'s ref callback — inside
402
640
  // React's commit — so the core's synchronous flush draws a "flushSync was
@@ -426,104 +664,228 @@ function TranscriptRows({
426
664
  : item.start < fold
427
665
  }
428
666
 
429
- // The catch-up strip's "jump" lives outside this scroll container, and the
430
- // recap row it targets is usually unmounted only the virtualizer knows
431
- // where it would be, so the strip is handed this closure instead of a DOM
432
- // query. Smooth on purpose: the jump IS a journey, and watching it travel
433
- // is what tells you how far back the boundary was. `stopScroll()` first,
434
- // because the pin spring is the other `scrollTop` writer and this is the
435
- // library's own switch for "the user is leaving the bottom".
667
+ // A new epoch means every remembered size is against the wrong metrics —
668
+ // the *measurements* included, which were taken at the old width. Dropping
669
+ // both together is the whole point: better estimates layered under
670
+ // stale-width measurements would be worse than either alone.
436
671
  //
437
- // And it has to *re-aim*. Every row between here and the boundary is
438
- // unmeasured, so the offset the virtualizer scrolls to is the sum of a few
439
- // hundred estimates; the real one only exists once those rows mount. A
440
- // single smooth scroll therefore lands a screen or two short — measured at
441
- // ~3300px off over 600 rows and it cannot self-correct, because the core
442
- // suppresses size-change adjustments outright while a smooth scroll is in
443
- // flight. So: aim, let the rows it crossed measure, aim again from the
444
- // better estimate. Each pass overshoots by less, and the moment the row is
445
- // actually mounted the DOM can finish the job exactly.
446
- //
447
- // The pending re-aim lives in a ref, and this is the whole reason: the effect
448
- // that publishes the closure has to re-run every render to keep `rows` fresh,
449
- // so anything held in its scope is torn down every render too — and a jump in
450
- // flight re-renders constantly, because that is what rows mounting *is*. A
451
- // timer in the closure would be cancelled by the very work it is waiting for.
452
- const aimTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
453
- useEffect(() => () => clearTimeout(aimTimer.current), [])
672
+ // The second half is not optional: `measure()` clears the size cache and a
673
+ // row re-enters it only when its ResizeObserver fires which needs a *size
674
+ // change*. A mounted row whose height happens to survive the width change
675
+ // (short lines that never rewrap) would keep its estimate forever, and
676
+ // wherever the estimate is off the transcript grows a phantom tail 2,052px
677
+ // of scrollable nothing after one sidebar toggle, on a real session. So the
678
+ // mounted rows are fed straight back in.
454
679
  useEffect(() => {
455
- if (!jumpToRecapRef) return
456
- jumpToRecapRef.current = () => {
457
- const index = rows.findIndex((row) => row.key === 'recap')
458
- if (index < 0) return
459
- stick.stopScroll()
460
- clearTimeout(aimTimer.current)
461
- const aim = (attempt: number) => {
462
- const row = scrollElement?.querySelector('[data-slot="recap"]')
463
- if (row) {
464
- row.scrollIntoView({ behavior: 'smooth', block: 'center' })
465
- return
466
- }
467
- if (attempt >= 6) return
468
- virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
469
- // Longer than one frame: the aim is only better once the rows crossed
470
- // have mounted *and* been measured, and that is a layout pass away.
471
- aimTimer.current = setTimeout(() => aim(attempt + 1), 300)
472
- }
473
- aim(0)
474
- }
475
- return () => {
476
- jumpToRecapRef.current = null
680
+ if (!terminal || !epoch) return
681
+ virtualizer.measure()
682
+ const container = rowsRef.current
683
+ if (!container) return
684
+ // Two sharp edges in the re-feed, both learned the hard way. Order:
685
+ // `resizeItem` diffs a measure against `measurementsCache`, and straight
686
+ // after `measure()` that array is still the pre-wipe one — an unchanged
687
+ // row diffs to zero against its own old measurement and the write is
688
+ // skipped; recomputing first (any measurement read does it) rebuilds the
689
+ // array from estimates, so the diff is real again. And `resizeItem`
690
+ // directly, not `measureElement(element)`: the latter is gated on the
691
+ // scroll state and silently drops a measure that lands while a scroll is
692
+ // still hot which a resize's own scroll anchoring makes routine.
693
+ virtualizer.getTotalSize()
694
+ for (const element of container.querySelectorAll<HTMLElement>('[data-index]')) {
695
+ const index = Number(element.getAttribute('data-index'))
696
+ if (Number.isInteger(index) && index >= 0)
697
+ virtualizer.resizeItem(index, element.getBoundingClientRect().height)
477
698
  }
699
+ }, [terminal, epoch, virtualizer])
700
+
701
+ // The jump machinery — the aim loop, the catch-up strip's jump, the re-pin
702
+ // — lives in `use-transcript-jumps.ts`; every jump on this surface comes
703
+ // through the one function it returns.
704
+ const jumpToRow = useTranscriptJumps({
705
+ rows,
706
+ terminal,
707
+ stickyPrompt,
708
+ epoch,
709
+ promptRows,
710
+ scrollElement,
711
+ rowsRef,
712
+ virtualizer,
713
+ stick,
714
+ jumpToRecapRef,
715
+ repinRef,
478
716
  })
479
717
 
718
+ // The scrubber. Interactivity follows the hover affordance — with
719
+ // `affordances={false}` the rail is passive paint (pointer-events off) and
720
+ // the native scrollbar stays; interactive, the rail IS the scrollbar, so the
721
+ // native one is hidden via an attribute on the scroll element.
722
+ const scrubInteractive = resolveAffordances(affordances).hover
723
+ const recapIndex = rows.findIndex((row) => row.key === 'recap')
724
+ const recapRow =
725
+ recapIndex >= 0
726
+ ? { rowIndex: recapIndex, label: (rows[recapIndex] as { line: string }).line }
727
+ : undefined
728
+ useEffect(() => {
729
+ if (!terminal || !scrubber || !scrubInteractive || !scrollElement) return
730
+ scrollElement.setAttribute('data-term-scrubber-host', '')
731
+ return () => scrollElement.removeAttribute('data-term-scrubber-host')
732
+ }, [terminal, scrubber, scrubInteractive, scrollElement])
733
+
734
+ /**
735
+ * The pinned prompt: the prompt's **first line**, held at the top of the
736
+ * scroller.
737
+ *
738
+ * One line, not the row: a pasted twenty-line prompt pinned whole covers the
739
+ * viewport and buries the very answer being read under it. So what pins is a
740
+ * **head** — one line tall, `overflow: hidden` — whose content is the same
741
+ * row rendered again, laid exactly over the real row's first line. It is a
742
+ * duplicate, which an earlier design here rejected — but that rejection was
743
+ * of a *separate header* with its own padding and its own idea of the
744
+ * gutter. This copy is the same component in the same column at the same
745
+ * width, so it aligns with the row beneath by construction, and while the
746
+ * row is in flow the overlay is pixel-identical and invisible. It takes no
747
+ * pointer events and is `aria-hidden`: the real row owns interaction and
748
+ * the accessibility tree; the head is paint.
749
+ *
750
+ * The pin itself is the browser's, not ours. Each prompt row renders inside a
751
+ * **lane**: an absolutely positioned strip spanning from the prompt's start to
752
+ * the next prompt's (its turn), with the head `position: sticky` inside it
753
+ * (its flow footprint cancelled by a negative bottom margin, so the real row
754
+ * sits at the lane's top as if the head were not there). The compositor does
755
+ * the pinning and the lane's bottom edge does the push-off — `sticky` is
756
+ * inert on an absolutely positioned element, but works unchanged on a child
757
+ * *of* one, confined to the lane's box. An earlier version clamped the row's
758
+ * transform from render instead, and paid for it every frame: any JS-written
759
+ * pin — React render or a raw scroll handler — runs behind the compositor
760
+ * thread, so the row wobbled under momentum scroll. With the lane there is
761
+ * no per-scroll JS and no lag.
762
+ *
763
+ * Which row — the last prompt at or above the fold, the question the answer
764
+ * on screen belongs to — falls out of the geometry: only the lane spanning
765
+ * the viewport top has its child stuck; every earlier lane has pushed its row
766
+ * off its bottom edge, every later one hasn't reached the top.
767
+ *
768
+ * The one job left to JS is keeping that row *mounted* once it scrolls far
769
+ * above the virtual window — which is exactly when it is working. That lives
770
+ * in the `rangeExtractor` above.
771
+ */
772
+ const measurements = virtualizer.measurementsCache
773
+
480
774
  return (
481
775
  <div
776
+ ref={rowsRef}
482
777
  data-slot='transcript-rows'
483
778
  className='relative w-full'
484
779
  style={{ height: virtualizer.getTotalSize() }}>
485
780
  {virtualizer.getVirtualItems().map((virtualRow) => {
486
781
  const row = rows[virtualRow.index]
782
+ // The inter-row gap, folded into each row so the measured height
783
+ // carries it: flex `gap` cannot reach absolutely positioned rows,
784
+ // and a pixel constant for the virtualizer's `gap` option would
785
+ // drift from the rem the layout is set in. On the measured wrapper,
786
+ // not the row div, so a nested row's left border still breaks
787
+ // across the gap as it did under flex. Skipped for the first row —
788
+ // a gap above it would be padding, not spacing.
789
+ // Every variant but terminal spaces every row alike. Terminal
790
+ // asks whether the pair belongs together — a tool call and its
791
+ // output get no blank line, exactly as the CLI leaves none.
792
+ const gapClass =
793
+ virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index))
794
+ ? gap.className
795
+ : undefined
796
+ const content =
797
+ 'run' in row ? (
798
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
799
+ <ToolRunRow items={row.run} />
800
+ </div>
801
+ ) : 'item' in row ? (
802
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item))}>
803
+ <TranscriptItemView
804
+ item={row.item}
805
+ fileUrl={fileUrl}
806
+ attachmentUrl={attachmentUrl}
807
+ hostImage={hostImage}
808
+ terminal={terminal}
809
+ />
810
+ </div>
811
+ ) : (
812
+ <RecapRow line={row.line} since={since} terminal={terminal} />
813
+ )
814
+ // A prompt row's sticky lane — see the pinned-prompt comment above.
815
+ // The lane is sized to the turn; the sticky **head** (one clipped
816
+ // line, the same content again) comes first with its flow footprint
817
+ // cancelled, and the *measured* element is the real row after it, so
818
+ // the virtualizer's heights are untouched by either. Both carry the
819
+ // gap class: the row because the gap is part of its measured height,
820
+ // the head so its one visible line sits on the same y while in flow —
821
+ // the pin parks that padding above the viewport edge when stuck.
822
+ // Positioned with `top`, NOT the translate every other row gets:
823
+ // `position: sticky` is resolved at layout time and a transform is
824
+ // paint-only, so under a translate the head would stick against the
825
+ // lane's un-translated box at the top of the list — observed as the
826
+ // row clamped to its lane's bottom edge, never pinning at all.
827
+ if (terminal && stickyPrompt && 'item' in row && row.item.kind === 'user') {
828
+ const next = promptRows.find((index) => index > virtualRow.index)
829
+ const laneEnd =
830
+ next === undefined
831
+ ? virtualizer.getTotalSize()
832
+ : (measurements[next]?.start ?? virtualRow.start)
833
+ return (
834
+ <StickyPromptLane
835
+ key={row.key}
836
+ top={virtualRow.start}
837
+ height={Math.max(laneEnd - virtualRow.start, 0)}
838
+ gapClass={gapClass}
839
+ scrollRoot={scrollElement}
840
+ index={virtualRow.index}
841
+ measureRef={virtualizer.measureElement}
842
+ content={content}
843
+ />
844
+ )
845
+ }
487
846
  return (
488
847
  <div
489
848
  key={row.key}
490
849
  ref={virtualizer.measureElement}
491
850
  data-index={virtualRow.index}
492
- className={cn(
493
- 'absolute inset-x-0 top-0',
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,
502
- )}
851
+ className={cn('absolute inset-x-0 top-0', gapClass)}
503
852
  style={{ transform: `translateY(${virtualRow.start}px)` }}>
504
- {'item' in row ? (
505
- <div
506
- className={cn(
507
- // Full-bleed hover: the row IS the affordance, so the
508
- // highlight has to reach past the content gutter.
509
- lines && '-mx-1 rounded-sm px-1 py-0.5 transition-colors hover:bg-surface-hover',
510
- // Already read: present, legible, and visibly behind you.
511
- boundary !== undefined && row.index < boundary && 'opacity-45',
512
- nestedClass(row.item, lines),
513
- )}>
514
- <TranscriptItemView
515
- item={row.item}
516
- fileUrl={fileUrl}
517
- attachmentUrl={attachmentUrl}
518
- hostImage={hostImage}
519
- />
520
- </div>
521
- ) : (
522
- <RecapRow line={row.line} since={since} />
523
- )}
853
+ {content}
524
854
  </div>
525
855
  )
526
856
  })}
857
+ {/* The overview ruler, portalled beside the scroll element rather than
858
+ rendered as content: it must not scroll with the rows it maps. It
859
+ lives here, not in the shell above, because everything it draws from
860
+ — the virtualizer's offsets, the epoch, the row list, the jump — is
861
+ this component's. The portal target is the Conversation root
862
+ (`relative`), the same containing block the scroll button uses. */}
863
+ {terminal && scrubber && scrollElement?.parentElement
864
+ ? createPortal(
865
+ <TerminalScrubber
866
+ items={items}
867
+ pendingApprovals={pendingApprovals}
868
+ recapRow={recapRow}
869
+ bookmarks={scrubberMarks ?? []}
870
+ rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
871
+ // The public memoized measurements array — `getTotalSize()` just
872
+ // above refreshed it, and with the calculator feeding
873
+ // `estimateSize` these starts are honest for unmounted rows too.
874
+ offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
875
+ sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
876
+ totalSize={virtualizer.getTotalSize()}
877
+ scrollOffset={virtualizer.scrollOffset ?? 0}
878
+ viewportH={virtualizer.scrollRect?.height ?? 0}
879
+ // To the top: a mark is where you start reading, not the middle of
880
+ // what you want to see.
881
+ onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
882
+ interactive={scrubInteractive}
883
+ fontSize={fontSize}
884
+ lineHeight={lineHeight}
885
+ />,
886
+ scrollElement.parentElement,
887
+ )
888
+ : null}
527
889
  </div>
528
890
  )
529
891
  }
@@ -544,9 +906,9 @@ export interface TranscriptProps {
544
906
  * on the host (codex's `image_gen`). Omit and those cards name the path. */
545
907
  hostImage?: (path: string) => Promise<string | undefined>
546
908
  /**
547
- * How a turn is drawn: `cards` (default, the chat convention) or `lines` —
548
- * full-width transparent line items with a gutter glyph, for hosts where
549
- * vertical space is scarce. See {@link TranscriptVariant}.
909
+ * How a turn is drawn: `cards` (default, the chat convention) or `terminal`,
910
+ * which is not a set of branches in these components but its own renderer.
911
+ * See {@link TranscriptVariant}.
550
912
  */
551
913
  variant?: TranscriptVariant
552
914
  /**
@@ -555,6 +917,42 @@ export interface TranscriptProps {
555
917
  * {@link TranscriptVariant}. See {@link TranscriptDensity}.
556
918
  */
557
919
  density?: TranscriptDensity
920
+ /** Terminal theme only: the character cell, in whole pixels. See
921
+ * {@link TerminalSurface}. */
922
+ fontSize?: number
923
+ lineHeight?: number
924
+ /** Terminal theme only: the pointer affordances a real terminal cannot offer.
925
+ * `false` for none. See {@link TerminalAffordances}. */
926
+ affordances?: TerminalAffordances | boolean
927
+ /** Terminal theme only: hold the prompt of the turn being read at the top of
928
+ * the scroller. The *real* row is pinned, not a copy — see `TranscriptRows`. */
929
+ stickyPrompt?: boolean
930
+ /**
931
+ * Terminal theme only: mount the overview-ruler scrubber — a 2ch rail of
932
+ * marks (your prompts, each turn's response and result as one mark, errors,
933
+ * the pending approval, the catch-up boundary) that replaces the native
934
+ * scrollbar. Ignored under `cards`: the rail's positions ride the height
935
+ * calculator, which has no claim there. With `affordances={false}` the rail
936
+ * degrades to passive paint — no drag, peek or click — and the native
937
+ * scrollbar stays. See {@link TerminalScrubber}.
938
+ */
939
+ scrubber?: boolean
940
+ /**
941
+ * Bookmarked item indices, painted as full-width marks on the rail. Paint
942
+ * only, deliberately: the store — and the affordance that writes it — is
943
+ * the client's, the way the unread watermarks are, not the panel's.
944
+ */
945
+ scrubberMarks?: readonly number[]
946
+ /**
947
+ * The attach replay is still landing (`useClaudeSession().replaying`): rows
948
+ * render, measure and pin exactly as normal but nothing paints, and a loading
949
+ * line shows in their place; when it flips false the settled tail appears in
950
+ * one frame. Hiding is by *visibility*, never by not mounting — see the
951
+ * comment at the render site. Optional: an embedder that never passes it
952
+ * gets today's behaviour, and a short or empty session holds for no visible
953
+ * time at all (the frame between attach and replay-complete).
954
+ */
955
+ replaying?: boolean
558
956
  /**
559
957
  * Catch-up: `from` is how many items had been seen last time, `since` when
560
958
  * that was. A recap row is drawn at that boundary and everything above it is
@@ -570,6 +968,14 @@ export interface TranscriptProps {
570
968
  * catch-up strip never touch it. `null` while no transcript is mounted.
571
969
  */
572
970
  jumpToRecapRef?: RefObject<(() => void) | null>
971
+ /**
972
+ * Filled with a closure that re-pins the transcript to the bottom, so a host
973
+ * can resume following after the reader has scrolled away. The panel presses
974
+ * it on send: choosing to say something is choosing to watch what happens
975
+ * next, and a transcript left parked where you were reading makes a sent
976
+ * message look like it did nothing at all.
977
+ */
978
+ repinRef?: RefObject<(() => void) | null>
573
979
  className?: string
574
980
  }
575
981
 
@@ -581,33 +987,71 @@ export function Transcript({
581
987
  hostImage,
582
988
  variant = 'cards',
583
989
  density = 'comfortable',
990
+ fontSize,
991
+ lineHeight,
992
+ affordances,
993
+ stickyPrompt = false,
994
+ scrubber,
995
+ scrubberMarks,
996
+ replaying = false,
584
997
  catchUp,
585
998
  jumpToRecapRef,
999
+ repinRef,
586
1000
  className,
587
1001
  }: TranscriptProps) {
588
- const lines = variant === 'lines'
1002
+ const terminal = variant === 'terminal'
589
1003
  const gap = ROW_GAP[variant][density]
590
1004
  const runStartedAt = useRunStart(state.status)
591
- const following = useSettled(state.items.length, state.status)
592
1005
  // A boundary at (or past) the end means nothing is new — no row, no dimming.
1006
+ //
1007
+ // That "past the end" arm now also covers a `/clear`. The mark a client
1008
+ // stored is an item index, and `conversation_reset` empties `items` while
1009
+ // `activityCount` stays monotonic (it is an unread cursor, not an item count
1010
+ // — a count that went backwards would silence the badge for good against a
1011
+ // monotonic watermark store). So a session returned to after a clear has a
1012
+ // boundary well past its few fresh rows and gets **no recap row**, which is
1013
+ // the honest answer: an index into a conversation that no longer exists
1014
+ // cannot say what you missed. Clamping it would land on `items.length` and
1015
+ // read as "nothing is new" — the same outcome, told less truthfully.
593
1016
  const boundary =
594
1017
  catchUp && catchUp.from > 0 && catchUp.from < state.items.length ? catchUp.from : undefined
595
1018
  const recap = useMemo(
596
1019
  () => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))),
597
1020
  [state, boundary],
598
1021
  )
599
- const rows = useMemo(() => {
600
- const out: TranscriptRow[] = []
601
- for (const [index, item] of state.items.entries()) {
602
- if (index === boundary && recap) out.push({ key: 'recap', line: recap })
603
- out.push({ key: `${item.kind}:${item.id}`, item, index })
604
- }
605
- return out
606
- }, [state.items, boundary, recap])
1022
+ const rows = useMemo<TranscriptRow[]>(() => {
1023
+ const fold = (from: number, to: number) =>
1024
+ terminalBlocks(state.items.slice(from, to), from, terminal)
1025
+ if (boundary === undefined || !recap) return fold(0, state.items.length)
1026
+ // Each side of the boundary folds separately, so a shell run never spans it:
1027
+ // "what happened while you were away" must not hide inside a count that also
1028
+ // covers what you have already read.
1029
+ return [
1030
+ ...fold(0, boundary),
1031
+ { key: 'recap' as const, line: recap },
1032
+ ...fold(boundary, state.items.length),
1033
+ ]
1034
+ }, [state.items, boundary, recap, terminal])
607
1035
  return (
608
1036
  <TranscriptVariantProvider value={variant}>
609
- <Conversation className={className} resize={following ? 'smooth' : 'instant'}>
610
- <ConversationContent className={cn(lines && 'gap-0 px-2 py-1.5')}>
1037
+ {/* The replay hold hides by VISIBILITY, never by not mounting. The rows
1038
+ must exist and lay out while hidden: the virtualizer measures them,
1039
+ the height epoch builds, and the follow pin settles on the real
1040
+ bottom — so the reveal is the removal of one style, a single paint of
1041
+ an already-settled tail, and the catch-up jump always fires against a
1042
+ measured list. (Unmounting instead would replay the entire
1043
+ mount-measure-correct churn, visibly, at reveal time.) `visibility`
1044
+ is the one hiding property a descendant can turn back ON, which is
1045
+ how the loading line below stays visible inside a hidden root — and
1046
+ the root is the right scope because the scrubber and the scroll
1047
+ button portal/position into it, not into the scroller. */}
1048
+ <Conversation className={cn(replaying && 'invisible', className)}>
1049
+ <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
1050
+ <TerminalShell
1051
+ active={terminal}
1052
+ fontSize={fontSize}
1053
+ lineHeight={lineHeight}
1054
+ affordances={affordances}>
611
1055
  {state.items.length === 0 && state.status !== 'starting' ? (
612
1056
  <SessionEmptyState
613
1057
  cwd={state.cwd}
@@ -620,24 +1064,87 @@ export function Transcript({
620
1064
  rows={rows}
621
1065
  boundary={boundary}
622
1066
  since={catchUp?.since}
623
- lines={lines}
1067
+ terminal={terminal}
1068
+ replaying={replaying}
1069
+ stickyPrompt={stickyPrompt}
624
1070
  gap={gap}
1071
+ fontSize={fontSize}
1072
+ lineHeight={lineHeight}
1073
+ items={state.items}
1074
+ pendingApprovals={state.pendingApprovals}
1075
+ scrubber={scrubber}
1076
+ scrubberMarks={scrubberMarks}
1077
+ affordances={affordances}
625
1078
  fileUrl={fileUrl}
626
1079
  attachmentUrl={attachmentUrl}
627
1080
  hostImage={hostImage}
628
1081
  jumpToRecapRef={jumpToRecapRef}
1082
+ repinRef={repinRef}
629
1083
  />
630
1084
  )}
631
1085
  {showLoader(state) ? (
632
- <Loader
633
- label={state.status === 'starting' ? 'Starting session…' : undefined}
634
- startedAt={runStartedAt}
635
- tokens={state.contextUsage?.totalTokens}
636
- className={cn(lines && 'py-0.5')}
637
- />
1086
+ terminal ? (
1087
+ // The CLI's own working line, and it is a *row of the transcript*
1088
+ // rather than a spinner floating over it — one blank line down,
1089
+ // like every other block.
1090
+ <>
1091
+ {state.items.length > 0 ? <div className='term-blank' aria-hidden /> : null}
1092
+ <WorkingRow
1093
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
1094
+ startedAt={runStartedAt}
1095
+ tokens={state.contextUsage?.totalTokens}
1096
+ />
1097
+ </>
1098
+ ) : (
1099
+ <Loader
1100
+ label={state.status === 'starting' ? 'Starting session…' : undefined}
1101
+ startedAt={runStartedAt}
1102
+ tokens={state.contextUsage?.totalTokens}
1103
+ />
1104
+ )
638
1105
  ) : null}
1106
+ </TerminalShell>
639
1107
  </ConversationContent>
640
1108
  <ConversationScrollButton />
1109
+ {/* What shows while the hold is on — and for a normal attach that is
1110
+ *nothing*. `wd-hold-appear` (theme.css) keeps it at `opacity: 0`
1111
+ and fades it in only after 600ms, so a healthy ~0.5s hold unmounts
1112
+ it before it ever paints. An unconditional line here was worse than
1113
+ no hold at all: it appeared and vanished inside half a second, which
1114
+ is the flicker the hold exists to remove, relocated to the top of
1115
+ the panel. Only a genuinely slow attach earns a placeholder, which
1116
+ is the case where a reader would otherwise think the panel is dead.
1117
+ An overlay rather than a flow row
1118
+ because the hidden content is at full height and pinned to its
1119
+ bottom; a row in flow would sit at the bottom edge. It mirrors
1120
+ `ConversationContent`'s wrapper (not the component itself — a
1121
+ second `StickToBottom.Content` would steal the library's content
1122
+ ref) so the paddings line up with the real rows'. */}
1123
+ {replaying ? (
1124
+ <div
1125
+ data-slot='transcript-hold'
1126
+ aria-hidden
1127
+ className='wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden'>
1128
+ <div
1129
+ className={cn(
1130
+ 'mx-auto w-full max-w-[var(--wd-content-max-w,48rem)]',
1131
+ !terminal && 'px-4 py-4',
1132
+ )}>
1133
+ {terminal ? (
1134
+ <TerminalSurface
1135
+ fontSize={fontSize}
1136
+ lineHeight={lineHeight}
1137
+ affordances={false}
1138
+ bleed='1ch'
1139
+ className='term-transcript'>
1140
+ <WorkingRow label='Loading…' />
1141
+ </TerminalSurface>
1142
+ ) : (
1143
+ <Loader label='Loading session…' />
1144
+ )}
1145
+ </div>
1146
+ </div>
1147
+ ) : null}
641
1148
  </Conversation>
642
1149
  </TranscriptVariantProvider>
643
1150
  )