@workerdeck/ui 0.20.0 → 0.22.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 (43) hide show
  1. package/README.md +3 -2
  2. package/build/{SessionPanel-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
  3. package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
  4. package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
  5. package/build/index.d.mts +309 -53
  6. package/build/index.mjs +534 -397
  7. package/build/index.mjs.map +1 -1
  8. package/build/scoped.css +3547 -0
  9. package/build/workspace.d.mts +10 -1
  10. package/build/workspace.mjs +4 -2
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +15 -7
  13. package/src/components/agent/Composer.tsx +10 -10
  14. package/src/components/agent/ContextDialog.tsx +3 -2
  15. package/src/components/agent/Conversation.tsx +1 -1
  16. package/src/components/agent/McpDialog.tsx +3 -1
  17. package/src/components/agent/Scrubber.tsx +248 -0
  18. package/src/components/agent/SessionBrowser.tsx +113 -293
  19. package/src/components/agent/SessionItem.tsx +538 -0
  20. package/src/components/agent/SessionList.tsx +3 -1
  21. package/src/components/agent/SessionPanel.tsx +112 -21
  22. package/src/components/agent/SessionStatusIcon.tsx +43 -0
  23. package/src/components/agent/SessionSteps.tsx +118 -61
  24. package/src/components/agent/SessionWorkspace.tsx +11 -0
  25. package/src/components/agent/SkillsDialog.tsx +3 -2
  26. package/src/components/agent/StatusBar.tsx +1 -1
  27. package/src/components/agent/Transcript.tsx +147 -73
  28. package/src/components/agent/UsageDialog.tsx +3 -1
  29. package/src/components/agent/scrubber-marks.ts +277 -0
  30. package/src/components/terminal/PermissionPrompt.tsx +3 -0
  31. package/src/components/terminal/QuestionPrompt.tsx +3 -0
  32. package/src/components/terminal/StatusLine.tsx +3 -1
  33. package/src/components/ui/AlertDialog.tsx +23 -20
  34. package/src/components/ui/Dialog.tsx +26 -23
  35. package/src/components/ui/Menu.tsx +20 -17
  36. package/src/components/ui/PortalScope.tsx +27 -0
  37. package/src/components/ui/Select.tsx +19 -16
  38. package/src/components/ui/Tooltip.tsx +13 -10
  39. package/src/index.ts +5 -1
  40. package/src/styles/scoped.entry.css +16 -0
  41. package/src/styles/terminal.css +1 -1
  42. package/src/styles/theme.css +223 -8
  43. package/build/SessionPanel-CHktI2CF.mjs.map +0 -1
@@ -31,6 +31,7 @@ import { taskBrief } from '../terminal/tool-run.ts'
31
31
  import { taskBusy } from '../terminal/tool-run.ts'
32
32
  import { briefPx, estimateBlockPx } from '../terminal/height.ts'
33
33
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
34
+ import { Scrubber } from './Scrubber.tsx'
34
35
  import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
35
36
  import { useHeightEpoch } from './use-height-epoch.ts'
36
37
  import { useTranscriptJumps } from './use-transcript-jumps.ts'
@@ -320,59 +321,106 @@ function nestedClass(item: TranscriptItem, frameParentId?: string): string | und
320
321
  // here because this file is where consumers have always found them.
321
322
  export { rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
322
323
 
324
+ /** The cards head's one line: the prompt as a plain run of text. Deliberately
325
+ * not the Message component — a proportional card clipped by height is a
326
+ * sliced bubble, and un-styling one from CSS is a fight (see theme.css's
327
+ * sticky-prompt block for the other half of this decision). Prompt syntax
328
+ * stays literal — a 28px bar is a reminder of what was asked, not a rendering
329
+ * surface — and newlines collapse under the bar's `nowrap`. Attachment-only
330
+ * prompts fall back to the attachments' names so the bar is never blank. */
331
+ function promptHeadText(item: Extract<TranscriptItem, { kind: 'user' }>): string {
332
+ return item.text || (item.attachments ?? []).map((attachment) => attachment.name).join(', ')
333
+ }
334
+
323
335
  /**
324
336
  * A prompt row's sticky lane — the strip spanning its turn, leading with the
325
337
  * one-line pinned **head** (see the pinned-prompt comment in
326
338
  * {@link TranscriptRows}).
327
339
  *
340
+ * The head's content is the variant's: the terminal passes the row again
341
+ * (clipping it to one line is exact under a monospace grid), cards passes the
342
+ * prompt as plain text for theme.css to draw as a compact bar. Only the
343
+ * terminal head carries the row's gap class — its stuck geometry parks that
344
+ * padding above the viewport edge so the visible line docks at zero
345
+ * (terminal.css); the cards bar never aligns with the row in flow, so the gap
346
+ * stays off it entirely and the 1st prompt and the Nth share one geometry.
347
+ *
328
348
  * The head starts `visibility: hidden` and shows only while actually stuck —
329
349
  * an overlay that is visible in flow would sit on the real row's first line
330
350
  * and swallow its selection highlight, which reads as "the first line cannot
331
351
  * be selected". CSS cannot ask "am I stuck?", so a 1px sentinel at the head's
332
- * engage threshold (the line's own y) feeds an IntersectionObserver: sentinel
333
- * above the scrollport top → stuck. Transition-only callbacks this adds no
334
- * per-scroll work, and the pin itself is still the compositor's.
352
+ * engage threshold (the line's own y) answers it: sentinel above the
353
+ * scrollport top → stuck, read by a passive scroll listener. It was an
354
+ * IntersectionObserver once, for the "no per-scroll work" purity and that
355
+ * was a real bug: IO is edge-triggered, and an *instant* jump (the open-at-
356
+ * bottom pin, `jumpToRow`, a reveal) teleports the sentinel from below the
357
+ * viewport to above it between two observations — ratio 0 → 0, no threshold
358
+ * crossed, `isIntersecting` unchanged — so no entry is ever queued and the
359
+ * flag strands, in whichever direction the jump left it (observed: a session
360
+ * opened at the bottom, its prompt bar missing; the stale-true twin paints a
361
+ * bar over the real bubble). The flag needs level-triggered truth. The cost
362
+ * is two rect reads per scroll event per mounted lane — layout is clean
363
+ * during scrolling, and the pin itself is still the compositor's; only the
364
+ * bar's visibility rides the listener.
335
365
  */
336
366
  function StickyPromptLane({
337
367
  top,
338
368
  height,
339
369
  gapClass,
370
+ gapPx,
371
+ terminal,
340
372
  scrollRoot,
341
373
  index,
342
374
  measureRef,
375
+ head,
343
376
  content,
344
377
  }: {
345
378
  top: number
346
379
  height: number
347
380
  gapClass?: string | false
381
+ /** The gap's size in px (`ROW_GAP[...].px`) — the cards sentinel offset,
382
+ * where the terminal uses `--term-line` (exact against its own cell where a
383
+ * px constant would drift). */
384
+ gapPx: number
385
+ terminal: boolean
348
386
  scrollRoot: HTMLElement | null
349
387
  index: number
350
388
  measureRef: (element: HTMLDivElement | null) => void
389
+ /** What the pinned head shows — see the component comment. */
390
+ head: ReactNode
351
391
  content: ReactNode
352
392
  }) {
353
393
  const headRef = useRef<HTMLDivElement | null>(null)
354
394
  const sentinelRef = useRef<HTMLDivElement | null>(null)
395
+ // `top`/`height`/`gapClass` are deps because they move the sentinel without
396
+ // any scroll: a measurement refinement re-positions the lane under a still
397
+ // scroller, and only a fresh evaluation notices. Scroll covers the rest —
398
+ // including programmatic jumps, which fire a scroll event like any other
399
+ // write of `scrollTop`.
355
400
  useEffect(() => {
356
- const head = headRef.current
401
+ const headElement = headRef.current
357
402
  const sentinel = sentinelRef.current
358
- if (!head || !sentinel || !scrollRoot) return
359
- const observer = new IntersectionObserver(
360
- ([entry]) => {
361
- if (!entry) return
362
- // Above the scrollport, not merely out of it — a lane still below the
363
- // viewport has its sentinel non-intersecting too.
364
- const stuck =
365
- !entry.isIntersecting &&
366
- entry.boundingClientRect.top < (entry.rootBounds?.top ?? 0)
367
- head.toggleAttribute('data-stuck', stuck)
368
- },
369
- { root: scrollRoot },
370
- )
371
- observer.observe(sentinel)
372
- return () => observer.disconnect()
373
- }, [scrollRoot])
403
+ if (!headElement || !sentinel || !scrollRoot) return
404
+ const evaluate = () => {
405
+ // Strictly above the scrollport's top edge — at exact equality the real
406
+ // row's first line is itself flush with the top, and the head must not
407
+ // cover it.
408
+ const stuck =
409
+ sentinel.getBoundingClientRect().top < scrollRoot.getBoundingClientRect().top
410
+ headElement.toggleAttribute('data-stuck', stuck)
411
+ }
412
+ evaluate()
413
+ scrollRoot.addEventListener('scroll', evaluate, { passive: true })
414
+ return () => scrollRoot.removeEventListener('scroll', evaluate)
415
+ }, [scrollRoot, top, height, gapClass])
374
416
  return (
375
- <div data-sticky-lane='' className='absolute inset-x-0' style={{ top, height }}>
417
+ // The attribute VALUE is the styling seam: terminal.css matches the bare
418
+ // attribute under its `[data-terminal]` scope, theme.css keys the cards
419
+ // bar on `[data-sticky-lane='cards']` — no `:not()` acrobatics either side.
420
+ <div
421
+ data-sticky-lane={terminal ? 'terminal' : 'cards'}
422
+ className='absolute inset-x-0'
423
+ style={{ top, height }}>
376
424
  {/* The head rides in its own absolutely positioned sub-lane rather than
377
425
  in flow with a cancelled footprint: sticky confinement clamps the
378
426
  *margin* box, and a negative bottom margin shrinks that box to zero
@@ -381,15 +429,15 @@ function StickyPromptLane({
381
429
  Out of flow, the border box is what gets clamped, and the push-off
382
430
  lands exactly at the lane's bottom edge. */}
383
431
  <div data-sticky-headlane='' aria-hidden>
384
- <div ref={headRef} data-sticky-head='' className={gapClass || undefined}>
385
- {content}
432
+ <div ref={headRef} data-sticky-head='' className={(terminal && gapClass) || undefined}>
433
+ {head}
386
434
  </div>
387
435
  </div>
388
436
  <div
389
437
  ref={sentinelRef}
390
438
  aria-hidden
391
439
  className='absolute left-0 w-px'
392
- style={{ top: gapClass ? 'var(--term-line)' : 0, height: 1 }}
440
+ style={{ top: gapClass ? (terminal ? 'var(--term-line)' : gapPx) : 0, height: 1 }}
393
441
  />
394
442
  <div ref={measureRef} data-index={index} className={gapClass || undefined}>
395
443
  {content}
@@ -525,7 +573,7 @@ function TranscriptRows({
525
573
  enabled: false,
526
574
  promptRows: [],
527
575
  })
528
- pinRef.current = { enabled: terminal && stickyPrompt, promptRows }
576
+ pinRef.current = { enabled: stickyPrompt, promptRows }
529
577
  useEffect(() => {
530
578
  setScrollElement(stick.scrollRef.current)
531
579
  }, [stick.scrollRef])
@@ -882,13 +930,15 @@ function TranscriptRows({
882
930
  </div>
883
931
  )
884
932
  // A prompt row's sticky lane — see the pinned-prompt comment above.
885
- // The lane is sized to the turn; the sticky **head** (one clipped
886
- // line, the same content again) comes first with its flow footprint
887
- // cancelled, and the *measured* element is the real row after it, so
888
- // the virtualizer's heights are untouched by either. Both carry the
889
- // gap class: the row because the gap is part of its measured height,
890
- // the head so its one visible line sits on the same y while in flow —
891
- // the pin parks that padding above the viewport edge when stuck.
933
+ // The lane is sized to the turn; the sticky **head** comes first, out
934
+ // of flow, and the *measured* element is the real row after it, so
935
+ // the virtualizer's heights are untouched by either. Under terminal
936
+ // the head is the same content again clipped to one line, and carries
937
+ // the row's gap class so its visible line sits on the same y while in
938
+ // flow (the pin parks that padding above the viewport edge when
939
+ // stuck). Under cards the head is the prompt as plain text — theme.css
940
+ // draws it as a compact bar — and carries no gap class: it is never
941
+ // an in-flow overlay of the row, so it has no y to match.
892
942
  // Positioned with `top`, NOT the translate every other row gets:
893
943
  // `position: sticky` is resolved at layout time and a transform is
894
944
  // paint-only, so under a translate the head would stick against the
@@ -897,7 +947,6 @@ function TranscriptRows({
897
947
  // Same predicate as `promptRows` above — the lane and the forced range
898
948
  // must agree on which rows are prompts.
899
949
  if (
900
- terminal &&
901
950
  stickyPrompt &&
902
951
  'item' in row &&
903
952
  row.item.kind === 'user' &&
@@ -914,9 +963,12 @@ function TranscriptRows({
914
963
  top={virtualRow.start}
915
964
  height={Math.max(laneEnd - virtualRow.start, 0)}
916
965
  gapClass={gapClass}
966
+ gapPx={gap.px}
967
+ terminal={terminal}
917
968
  scrollRoot={scrollElement}
918
969
  index={virtualRow.index}
919
970
  measureRef={virtualizer.measureElement}
971
+ head={terminal ? content : promptHeadText(row.item)}
920
972
  content={content}
921
973
  />
922
974
  )
@@ -938,31 +990,38 @@ function TranscriptRows({
938
990
  — the virtualizer's offsets, the epoch, the row list, the jump — is
939
991
  this component's. The portal target is the Conversation root
940
992
  (`relative`), the same containing block the scroll button uses. */}
941
- {terminal && scrubber && scrollElement?.parentElement
993
+ {scrubber && scrollElement?.parentElement
942
994
  ? createPortal(
943
- <TerminalScrubber
944
- items={items}
945
- pendingApprovals={pendingApprovals}
946
- recapRow={recapRow}
947
- bookmarks={scrubberMarks ?? []}
948
- frameParentId={frameParentId}
949
- rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
950
- positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
951
- // The public memoized measurements array — `getTotalSize()` just
952
- // above refreshed it, and with the calculator feeding
953
- // `estimateSize` these starts are honest for unmounted rows too.
954
- offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
955
- sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
956
- totalSize={virtualizer.getTotalSize()}
957
- scrollOffset={virtualizer.scrollOffset ?? 0}
958
- viewportH={virtualizer.scrollRect?.height ?? 0}
959
- // To the top: a mark is where you start reading, not the middle of
960
- // what you want to see.
961
- onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
962
- interactive={scrubInteractive}
963
- fontSize={fontSize}
964
- lineHeight={lineHeight}
965
- />,
995
+ terminal ? (
996
+ <TerminalScrubber
997
+ items={items}
998
+ pendingApprovals={pendingApprovals}
999
+ recapRow={recapRow}
1000
+ bookmarks={scrubberMarks ?? []}
1001
+ frameParentId={frameParentId}
1002
+ rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
1003
+ positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
1004
+ offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
1005
+ sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
1006
+ totalSize={virtualizer.getTotalSize()}
1007
+ scrollOffset={virtualizer.scrollOffset ?? 0}
1008
+ viewportH={virtualizer.scrollRect?.height ?? 0}
1009
+ onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
1010
+ interactive={scrubInteractive}
1011
+ fontSize={fontSize}
1012
+ lineHeight={lineHeight}
1013
+ />
1014
+ ) : (
1015
+ <Scrubber
1016
+ items={items}
1017
+ pendingApprovals={pendingApprovals}
1018
+ recapItemIndex={recapIndex >= 0 ? boundary : undefined}
1019
+ bookmarks={scrubberMarks}
1020
+ frameParentId={frameParentId}
1021
+ interactive={scrubInteractive}
1022
+ onJumpToItem={(itemIndex) => jumpToRow(rowIndexForItem(rows, itemIndex), 'start')}
1023
+ />
1024
+ ),
966
1025
  scrollElement.parentElement,
967
1026
  )
968
1027
  : null}
@@ -1004,17 +1063,21 @@ export interface TranscriptProps {
1004
1063
  /** Terminal theme only: the pointer affordances a real terminal cannot offer.
1005
1064
  * `false` for none. See {@link TerminalAffordances}. */
1006
1065
  affordances?: TerminalAffordances | boolean
1007
- /** Terminal theme only: hold the prompt of the turn being read at the top of
1008
- * the scroller. The *real* row is pinned, not a copy see `TranscriptRows`. */
1066
+ /** Hold the prompt of the turn being read at the top of the scroller. Works
1067
+ * in both variants: the terminal clips to one line, cards shows a frosted
1068
+ * bar. The *real* row is pinned, not a copy — see `TranscriptRows`. */
1009
1069
  stickyPrompt?: boolean
1010
1070
  /**
1011
- * Terminal theme only: mount the overview-ruler scrubber — a 2ch rail of
1012
- * marks (your prompts, each turn's response and result as one mark, errors,
1013
- * the pending approval, the catch-up boundary) that replaces the native
1014
- * scrollbar. Ignored under `cards`: the rail's positions ride the height
1015
- * calculator, which has no claim there. With `affordances={false}` the rail
1016
- * degrades to passive paint no drag, peek or click and the native
1017
- * scrollbar stays. See {@link TerminalScrubber}.
1071
+ * Mount the overview scrubber — a 12px rail of marks (your prompts, each
1072
+ * turn's response and result as one mark, errors, the pending approval, the
1073
+ * catch-up boundary) over the scroller's right edge. Two rails behind one
1074
+ * prop: under `terminal` it is the pixel-exact ruler that replaces the
1075
+ * native scrollbar (positions ride the height calculator; drag scrubs), and
1076
+ * under `cards` it is the **proportional annotation rail**positioned by
1077
+ * `itemIndex / items.length`, because proportional text gives the
1078
+ * calculator no claim there — where the native scrollbar stays and the rail
1079
+ * only peeks and jumps. With `affordances={false}` either rail degrades to
1080
+ * passive paint — no drag, peek or click.
1018
1081
  */
1019
1082
  scrubber?: boolean
1020
1083
  /**
@@ -1102,6 +1165,12 @@ export interface TranscriptProps {
1102
1165
  * sessions list instead.
1103
1166
  */
1104
1167
  onOpenSubagent?: (toolUseId: string) => void
1168
+ /**
1169
+ * Replaces the default empty state when the transcript has no items. Pass a
1170
+ * `ReactNode` to show your product's own onboarding instead of WorkerDeck's
1171
+ * generic "`>_` Tell the agent what to do." placeholder.
1172
+ */
1173
+ emptyState?: ReactNode
1105
1174
  className?: string
1106
1175
  }
1107
1176
 
@@ -1126,6 +1195,7 @@ export function Transcript({
1126
1195
  reveal,
1127
1196
  frame,
1128
1197
  onOpenSubagent,
1198
+ emptyState,
1129
1199
  className,
1130
1200
  }: TranscriptProps) {
1131
1201
  const terminal = variant === 'terminal'
@@ -1231,12 +1301,16 @@ export function Transcript({
1231
1301
  </div>
1232
1302
  ) : null
1233
1303
  ) : items.length === 0 && state.status !== 'starting' ? (
1234
- <SessionEmptyState
1235
- cwd={state.cwd}
1236
- hasCommands={!!state.commands?.length}
1237
- hasSkills={!!state.skills?.some((s) => s.enabled)}
1238
- canBrowseFiles={canBrowseFiles}
1239
- />
1304
+ emptyState !== undefined ? (
1305
+ emptyState
1306
+ ) : (
1307
+ <SessionEmptyState
1308
+ cwd={state.cwd}
1309
+ hasCommands={!!state.commands?.length}
1310
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
1311
+ canBrowseFiles={canBrowseFiles}
1312
+ />
1313
+ )
1240
1314
  ) : null}
1241
1315
  {frame && frameTask === undefined && !replaying ? null : (
1242
1316
  <TranscriptRows
@@ -1316,7 +1390,7 @@ export function Transcript({
1316
1390
  className='wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden'>
1317
1391
  <div
1318
1392
  className={cn(
1319
- 'mx-auto w-full max-w-[var(--wd-content-max-w,48rem)]',
1393
+ 'mx-auto w-full max-w-[var(--wd-transcript-max-width)]',
1320
1394
  !terminal && 'px-4 py-4',
1321
1395
  )}>
1322
1396
  {terminal ? (
@@ -26,6 +26,7 @@ export interface UsageDialogProps {
26
26
  updatedAt?: number
27
27
  open: boolean
28
28
  onOpenChange: (open: boolean) => void
29
+ className?: string
29
30
  }
30
31
 
31
32
  /**
@@ -40,11 +41,12 @@ export function UsageDialog({
40
41
  updatedAt,
41
42
  open,
42
43
  onOpenChange,
44
+ className,
43
45
  }: UsageDialogProps) {
44
46
  const now = useMinuteClock(open)
45
47
  return (
46
48
  <Dialog open={open} onOpenChange={onOpenChange}>
47
- <DialogContent>
49
+ <DialogContent className={className}>
48
50
  <DialogHeader
49
51
  title='Usage'
50
52
  description={engine === 'claude' ? 'Claude Code' : engine}
@@ -0,0 +1,277 @@
1
+ import type { TranscriptItem } from '@workerdeck/react'
2
+ import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
+ import { parentOf } from '../terminal/blocks.ts'
4
+
5
+ /**
6
+ * The scrubber's mark model, shared between the two rails.
7
+ *
8
+ * The terminal scrubber (`terminal/scrubber.tsx`) positions marks in **pixel
9
+ * space** — the virtualizer's row offsets, honest because the height calculator
10
+ * feeds `estimateSize`. The cards variant has no such claim (proportional text,
11
+ * variable row heights), so its rail (`agent/Scrubber.tsx`) positions marks in
12
+ * **index space**: `itemIndex / items.length` of the rail. Less precise, but it
13
+ * still answers the reader's questions — where did I type, where did it fail,
14
+ * where is the approval waiting.
15
+ *
16
+ * What is shared here is the *classification*: which items earn a mark, which
17
+ * lane a mark lives in, and who wins the colour when marks merge. The walk in
18
+ * {@link buildMarks} mirrors `buildClusters`'s first half (the segment
19
+ * machinery, sub-agent detection, bookmark/recap injection) minus everything
20
+ * that needs a row model. One deliberate divergence: the terminal marks a
21
+ * failed tool call only when it is its row's *outcome* (a fold's last member),
22
+ * because that is what its transcript reddens; cards folds nothing
23
+ * (`terminalBlocks` with `fold=false`), every failed top-level call reddens its
24
+ * own card, so every one marks — the same "the rail marks what the transcript
25
+ * reddens" rule, read against a surface with no folds.
26
+ */
27
+
28
+ export type Lane = 'l' | 'r' | 'f'
29
+ export type MarkKind =
30
+ | 'user'
31
+ | 'subagent'
32
+ | 'turn'
33
+ | 'turnFailed'
34
+ | 'toolFailed'
35
+ | 'error'
36
+ | 'approval'
37
+ | 'recap'
38
+ | 'bookmark'
39
+
40
+ export type Mark = {
41
+ kind: MarkKind
42
+ /** The jump anchor (for a turn mark: the paired response). */
43
+ itemIndex: number
44
+ /** The `turn_result` behind a right-lane mark — the peek shows its done-line. */
45
+ turnIndex?: number
46
+ }
47
+
48
+ /** Members keep their own y: a dense transcript chain-merges a lane into one
49
+ * tall bar, and the bar answers the pointer by its *nearest member* — a press
50
+ * or peek at the middle of the bar must not act on the mark that happened to
51
+ * found the cluster. */
52
+ export type Cluster = {
53
+ lane: Lane
54
+ kind: MarkKind
55
+ y: number
56
+ h: number
57
+ marks: { mark: Mark; y: number }[]
58
+ }
59
+
60
+ /** The member closest to a rail-space y — what a press or peek on a merged
61
+ * cluster resolves to. */
62
+ export function nearestMember(cluster: Cluster, y: number): Mark | undefined {
63
+ let best: { mark: Mark; y: number } | undefined
64
+ for (const member of cluster.marks)
65
+ if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) best = member
66
+ return best?.mark
67
+ }
68
+
69
+ /** The two lanes are channels, not classes: left is what went *in* (your
70
+ * prompts, the sub-agents you dispatched), right is what came *out* (each
71
+ * turn's answer, and everything that went wrong producing one). Full width is
72
+ * for what is not a channel at all: the waiting approval, a bookmark, the
73
+ * catch-up seam. See the terminal scrubber for the full argument. */
74
+ export const LANE: Record<MarkKind, Lane> = {
75
+ user: 'l',
76
+ subagent: 'l',
77
+ turn: 'r',
78
+ turnFailed: 'r',
79
+ toolFailed: 'r',
80
+ error: 'r',
81
+ approval: 'f',
82
+ recap: 'f',
83
+ bookmark: 'f',
84
+ }
85
+
86
+ /** Who wins the colour when marks merge. */
87
+ export const LOUDNESS: Record<MarkKind, number> = {
88
+ approval: 7,
89
+ error: 6,
90
+ turnFailed: 5,
91
+ toolFailed: 4,
92
+ user: 3,
93
+ turn: 2,
94
+ bookmark: 1,
95
+ subagent: 1,
96
+ recap: 0,
97
+ }
98
+
99
+ export const KIND_NAME: Record<MarkKind, string> = {
100
+ user: 'you',
101
+ subagent: 'sub-agent',
102
+ turn: 'response · turn end',
103
+ turnFailed: 'turn failed',
104
+ toolFailed: 'tool failed',
105
+ error: 'error',
106
+ approval: 'pending approval',
107
+ recap: 'catch-up boundary',
108
+ bookmark: 'bookmark',
109
+ }
110
+
111
+ /** The floor: an index-proportional mark has no extent to draw, so this is
112
+ * usually the height too — 2px keeps a tick findable. */
113
+ export const MIN_MARK = 2
114
+
115
+ export const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
116
+ `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
117
+
118
+ export function excerpt(item: TranscriptItem): string {
119
+ switch (item.kind) {
120
+ case 'user':
121
+ case 'assistant_text':
122
+ case 'thinking':
123
+ case 'notice':
124
+ return item.text
125
+ case 'tool_call':
126
+ return `${item.name}(${toolInputPreview(item.input)})`
127
+ case 'turn_result':
128
+ return doneLine(item)
129
+ case 'file_delivered':
130
+ return item.path
131
+ default:
132
+ return ''
133
+ }
134
+ }
135
+
136
+ /** One right-lane mark per segment, emitted when the segment closes — by the
137
+ * next prompt, by its own turn end, or by running out of items (which is what
138
+ * a replayed history is made of, since `#backfillHistory` carries no turn
139
+ * rows). A `turn_result` *decorates* the answer rather than conjuring the
140
+ * mark, so a live answer with no turn end yet is still on the rail. */
141
+ type Segment = { response?: number; turn?: number; failed?: boolean }
142
+
143
+ export interface BuildMarksOptions {
144
+ /** The sub-agent takeover's parent id, when the rail belongs to a frame — it
145
+ * is what "top level" means here (`undefined` at the conversation's own
146
+ * level). Inside a frame every narration step is its own mark: the stream
147
+ * carries no prompts and no `turn_result` for the segment machinery to work
148
+ * with, and a fifty-step agent run is exactly where a rail earns its keep. */
149
+ frameParentId?: string
150
+ /** Bookmarked item indices. Paint only — the store is the client's. */
151
+ bookmarks?: readonly number[]
152
+ /** The catch-up boundary's item index, when the recap is spliced in. */
153
+ recapItemIndex?: number
154
+ }
155
+
156
+ export function buildMarks(
157
+ items: readonly TranscriptItem[],
158
+ { frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
159
+ ): Mark[] {
160
+ const marks: Mark[] = []
161
+ // Which top-level calls a sub-agent ran inside — by `parentToolUseId`, never
162
+ // by the spawning call's *name*: `Task` is the SDK's convention (a background
163
+ // agent arrives as `Agent`), and an id other items demonstrably nest under IS
164
+ // a sub-agent whatever spawned it.
165
+ const subagentParents = new Set<string>()
166
+ for (const item of items) {
167
+ const parent = parentOf(item)
168
+ if (parent !== undefined) subagentParents.add(parent)
169
+ }
170
+ let segment: Segment = {}
171
+ const closeSegment = () => {
172
+ const anchor = segment.response ?? segment.turn
173
+ if (anchor !== undefined) {
174
+ marks.push({
175
+ kind: segment.failed ? 'turnFailed' : 'turn',
176
+ itemIndex: anchor,
177
+ turnIndex: segment.turn,
178
+ })
179
+ }
180
+ segment = {}
181
+ }
182
+ items.forEach((item, index) => {
183
+ // The dispatch itself. Deliberately not part of the chain below: a `Task`
184
+ // whose own result errored earns a red tick in the response lane *and*
185
+ // this mark in the input lane — one says a sub-agent ran here, the other
186
+ // says it came back broken.
187
+ if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
188
+ marks.push({ kind: 'subagent', itemIndex: index })
189
+ }
190
+ // Top-level prompts only, like the answer check below: a sub-agent's brief
191
+ // is a `user` item too, and it would both paint a "you" mark for something
192
+ // nobody typed and close the segment mid-turn.
193
+ if (item.kind === 'user' && parentOf(item) === frameParentId) {
194
+ closeSegment()
195
+ marks.push({ kind: 'user', itemIndex: index })
196
+ } else if (item.kind === 'turn_result') {
197
+ segment.turn = index
198
+ segment.failed = item.isError
199
+ closeSegment()
200
+ } else if (item.kind === 'notice' && item.level === 'error') {
201
+ marks.push({ kind: 'error', itemIndex: index })
202
+ } else if (
203
+ // Both spellings are needed: an out-of-loop execution failure sets
204
+ // `status` with no `is_error` block to read, and an engine can flag
205
+ // `is_error` on a call this reducer has not settled yet. Top level only
206
+ // — a sub-agent's failed child is represented by the sub-agent mark and
207
+ // the Task's own red tick, exactly as the terminal rail does.
208
+ item.kind === 'tool_call' &&
209
+ parentOf(item) === frameParentId &&
210
+ (item.status === 'failed' || item.result?.isError === true)
211
+ ) {
212
+ marks.push({ kind: 'toolFailed', itemIndex: index })
213
+ } else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
214
+ if (frameParentId !== undefined) {
215
+ marks.push({ kind: 'turn', itemIndex: index })
216
+ return
217
+ }
218
+ segment.response = index
219
+ }
220
+ })
221
+ // A history that ends mid-segment still has an answer in it.
222
+ closeSegment()
223
+ for (const index of bookmarks)
224
+ if (index >= 0 && index < items.length) marks.push({ kind: 'bookmark', itemIndex: index })
225
+ if (recapItemIndex !== undefined) marks.push({ kind: 'recap', itemIndex: recapItemIndex })
226
+ return marks
227
+ }
228
+
229
+ /**
230
+ * Place marks proportionally — y = `itemIndex / itemCount` of the rail, every
231
+ * mark one item's share tall (floored at {@link MIN_MARK}) — then merge
232
+ * adjacent ones per lane, the loudest colour winning. The same merge rule as
233
+ * the terminal rail; only the position source differs.
234
+ */
235
+ export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
236
+ const count = Math.max(1, itemCount)
237
+ const h = Math.max(MIN_MARK, Math.round(railH / count))
238
+ const lanes = new Map<Lane, { mark: Mark; y: number }[]>()
239
+ for (const mark of marks) {
240
+ const y = Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH))
241
+ const lane = LANE[mark.kind]
242
+ const list = lanes.get(lane) ?? []
243
+ list.push({ mark, y })
244
+ lanes.set(lane, list)
245
+ }
246
+ const clusters: Cluster[] = []
247
+ for (const [lane, list] of lanes) {
248
+ list.sort((a, b) => a.y - b.y)
249
+ let current: Cluster | null = null
250
+ for (const { mark, y } of list) {
251
+ // Merge when the gap is under a pixel; the merged mark grows and takes
252
+ // the loudest member's colour.
253
+ if (current && y <= current.y + current.h + 1) {
254
+ current.h = Math.max(current.h, y + h - current.y)
255
+ if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) current.kind = mark.kind
256
+ current.marks.push({ mark, y })
257
+ } else {
258
+ current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
259
+ clusters.push(current)
260
+ }
261
+ }
262
+ }
263
+ return clusters
264
+ }
265
+
266
+ /** The approval is not an item — the prompt renders below the transcript — so
267
+ * its mark pins at the rail's foot, where the prompt is. Built by hand (no
268
+ * item to derive a position from), hence `marks: []`. */
269
+ export function approvalCluster(railH: number): Cluster {
270
+ return {
271
+ lane: LANE.approval,
272
+ kind: 'approval',
273
+ y: Math.max(0, railH - MIN_MARK),
274
+ h: MIN_MARK,
275
+ marks: [],
276
+ }
277
+ }