@workerdeck/ui 0.22.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -2,24 +2,15 @@ import { useEffect, useState } from 'react'
2
2
  import type { UsageWindowRow } from '@workerdeck/protocol'
3
3
  import { RotateCcw } from 'lucide-react'
4
4
  import { cn } from '../../lib/utils.ts'
5
- import {
6
- formatAgoPrecise,
7
- formatCountdown,
8
- formatRateLimitWindowLong,
9
- rateLimitWindowSeconds,
10
- } from '../../lib/format.ts'
5
+ import { formatAgoPrecise, formatCountdown, formatRateLimitWindowLong, rateLimitWindowSeconds } from '../../lib/format.ts'
6
+ import { meterTintClass } from '../../lib/status.ts'
11
7
 
12
- /**
13
- * Ticking clock — the countdowns and the pace markers both move with it, and a
14
- * minute is the finest resolution either of them prints.
15
- *
16
- * `active` is what a dialog passes its `open`: a closed panel's clock is a timer
17
- * re-rendering something nobody can see.
18
- */
19
8
  export function useMinuteClock(active = true): number {
20
9
  const [now, setNow] = useState(() => Date.now())
21
10
  useEffect(() => {
22
- if (!active) return
11
+ if (!active) {
12
+ return
13
+ }
23
14
  setNow(Date.now())
24
15
  const timer = setInterval(() => setNow(Date.now()), 60_000)
25
16
  return () => clearInterval(timer)
@@ -27,37 +18,7 @@ export function useMinuteClock(active = true): number {
27
18
  return now
28
19
  }
29
20
 
30
- const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
31
-
32
- /**
33
- * The plan's rate-limit windows, spelled out: how much of each is used, how that
34
- * compares to the pace that would spend the window exactly, and when it resets.
35
- *
36
- * The pace marker is the point. A bar alone says "17% used", which is only
37
- * alarming or reassuring once you know how far into the week you are — so every
38
- * window draws a tick at the elapsed share of its duration. Left of the tick is
39
- * under budget, right of it is ahead of it. The duration comes from the window
40
- * key (5h, 7d) because the engine reports a reset time and a percentage and
41
- * never a duration; a window whose key doesn't say gets no marker rather than a
42
- * guessed one.
43
- *
44
- * Its own component rather than the Usage dialog's private helper because two
45
- * surfaces draw these now — the session panel's Usage panel, from the session's
46
- * readings merged with the gateway's, and the dashboard's profile page, straight
47
- * off `ProfileInfo.usage` with no session in sight. Same account, same bars.
48
- */
49
- export function UsageMeters({
50
- windows,
51
- now,
52
- className,
53
- }: {
54
- /** In reading order — protocol's `orderUsageWindows`. */
55
- windows: UsageWindowRow[]
56
- /** A shared clock, when the caller already ticks one. Omitted, this ticks its
57
- * own for as long as it is mounted. */
58
- now?: number
59
- className?: string
60
- }) {
21
+ export function UsageMeters({ windows, now, className }: { windows: UsageWindowRow[]; now?: number; className?: string }) {
61
22
  const ownClock = useMinuteClock(now === undefined)
62
23
  const clock = now ?? ownClock
63
24
  return (
@@ -73,8 +34,6 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
73
34
  const { key: windowKey, info, updatedAt, inferredReset } = window
74
35
  const utilization = info.utilization ?? 0
75
36
  const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
76
- // Share of the window already elapsed — where usage *would* be if it were
77
- // spent evenly. Needs both a duration (from the key) and a reset time.
78
37
  const duration = rateLimitWindowSeconds(windowKey)
79
38
  const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
80
39
  const pace =
@@ -83,49 +42,35 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
83
42
  : undefined
84
43
  return (
85
44
  <div>
86
- <div className='flex items-baseline justify-between gap-3'>
87
- <span className='truncate text-body-sm text-fg-1'>
88
- {formatRateLimitWindowLong(windowKey)}
89
- </span>
90
- <span
91
- className={cn(
92
- 'shrink-0 font-mono text-body-sm font-medium',
93
- info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
94
- )}>
45
+ <div className="flex items-baseline justify-between gap-3">
46
+ <span className="truncate text-body-sm text-fg-1">{formatRateLimitWindowLong(windowKey)}</span>
47
+ <span className={cn('shrink-0 font-mono text-body-sm font-medium', info.status === 'rejected' ? 'text-danger' : 'text-fg-1')}>
95
48
  {utilization.toFixed(0)}% used
96
49
  </span>
97
50
  </div>
98
- <div className='relative mt-2 h-2 rounded-full bg-border'>
51
+ <div className="relative mt-2 h-2 rounded-full bg-border">
99
52
  <div
100
- className={cn('h-full rounded-full', usageTint(utilization))}
101
- // A floor, so a barely-touched window still shows a mark instead of
102
- // reading as missing data.
53
+ className={cn('h-full rounded-full', meterTintClass(utilization))}
103
54
  style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
104
55
  />
105
56
  {pace !== undefined ? (
106
57
  <span
107
58
  aria-hidden
108
- title='Spent evenly, usage would be here by now'
109
- className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
59
+ title="Spent evenly, usage would be here by now"
60
+ className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1"
110
61
  style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
111
62
  />
112
63
  ) : null}
113
64
  </div>
114
- <div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
65
+ <div className="mt-1.5 flex items-center gap-3 text-label text-fg-4">
115
66
  {resetsAtMs !== undefined ? (
116
- <span className='inline-flex items-center gap-1'>
117
- <RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
67
+ <span className="inline-flex items-center gap-1">
68
+ <RotateCcw className="size-3" /> Resets in {formatCountdown(resetsAtMs, now)}
118
69
  </span>
119
70
  ) : null}
120
- {info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
121
- {info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
122
- {/* Said plainly rather than drawn as a fact: the gateway zeroed this
123
- because the reading's own reset time passed with nothing newer, so 0
124
- is a floor — the account may have been spent elsewhere since. */}
71
+ {info.isUsingOverage ? <span className="text-warning">overage</span> : null}
72
+ {info.status === 'rejected' ? <span className="text-danger">limit reached</span> : null}
125
73
  {inferredReset ? <span>window reset · nothing reported since</span> : null}
126
- {/* Per window, because they no longer share one clock: a reading learned
127
- from a sibling session minutes ago can sit beside one this session
128
- last heard about yesterday. */}
129
74
  {updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
130
75
  </div>
131
76
  </div>
@@ -1,39 +1,17 @@
1
1
  import { useEffect, useState } from 'react'
2
2
 
3
- /**
4
- * The brand mark's pulse, as characters — the working marker every surface in the
5
- * transcript animates.
6
- *
7
- * These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
8
- * state"): a dot, an outline, a semi and a full diamond, built in the SVG from
9
- * two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
10
- * clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
11
- * medium a transcript row actually has.
12
- *
13
- * BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
14
- * *ambiguous width*, so they can render double-width in a terminal under an
15
- * East-Asian locale and shift every line with them. They are safe wherever the
16
- * glyph is centred in a fixed-width box, which is what the terminal theme's
17
- * gutter cell (`.term-gutter`, one `--term-cell` wide) is. Anything writing to a
18
- * real terminal must use the ASCII set instead.
19
- */
20
3
  export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
21
4
  export const PULSE_MS = 150
22
5
 
23
- /**
24
- * The resting state. Stopping the animation lands on the complete mark rather
25
- * than on a half-drawn frame — the same property that makes the SVG's
26
- * `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
27
- */
28
6
  export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
29
7
 
30
- /** The OS-level "stop moving things" setting. A spinner is decoration — the word
31
- * beside it carries the meaning — so honouring this costs nothing. */
32
8
  export function usePrefersReducedMotion(): boolean {
33
9
  const [reduced, setReduced] = useState(false)
34
10
  useEffect(() => {
35
11
  const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
36
- if (!query) return
12
+ if (!query) {
13
+ return
14
+ }
37
15
  setReduced(query.matches)
38
16
  const onChange = () => setReduced(query.matches)
39
17
  query.addEventListener('change', onChange)
@@ -42,20 +20,19 @@ export function usePrefersReducedMotion(): boolean {
42
20
  return reduced
43
21
  }
44
22
 
45
- /**
46
- * The current pulse frame, ticking while `animated`. Callers mount this only
47
- * while something is actually in flight, so nothing here runs on an idle
48
- * session; with reduced motion, or when not animating, it holds at rest.
49
- */
50
23
  export function usePulse(animated: boolean): string {
51
24
  const reduced = usePrefersReducedMotion()
52
25
  const running = animated && !reduced
53
26
  const [frame, setFrame] = useState(0)
54
27
  useEffect(() => {
55
- if (!running) return
28
+ if (!running) {
29
+ return
30
+ }
56
31
  const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
57
32
  return () => clearInterval(timer)
58
33
  }, [running])
59
- if (!running) return PULSE_REST
34
+ if (!running) {
35
+ return PULSE_REST
36
+ }
60
37
  return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
61
38
  }
@@ -2,53 +2,15 @@ import type { TranscriptItem } from '@workerdeck/react'
2
2
  import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
3
  import { parentOf } from '../terminal/blocks.ts'
4
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
5
  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'
6
+ export type MarkKind = 'user' | 'subagent' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
39
7
 
40
8
  export type Mark = {
41
9
  kind: MarkKind
42
- /** The jump anchor (for a turn mark: the paired response). */
43
10
  itemIndex: number
44
- /** The `turn_result` behind a right-lane mark — the peek shows its done-line. */
45
11
  turnIndex?: number
46
12
  }
47
13
 
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
14
  export type Cluster = {
53
15
  lane: Lane
54
16
  kind: MarkKind
@@ -57,20 +19,16 @@ export type Cluster = {
57
19
  marks: { mark: Mark; y: number }[]
58
20
  }
59
21
 
60
- /** The member closest to a rail-space y — what a press or peek on a merged
61
- * cluster resolves to. */
62
22
  export function nearestMember(cluster: Cluster, y: number): Mark | undefined {
63
23
  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
24
+ for (const member of cluster.marks) {
25
+ if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) {
26
+ best = member
27
+ }
28
+ }
66
29
  return best?.mark
67
30
  }
68
31
 
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
32
  export const LANE: Record<MarkKind, Lane> = {
75
33
  user: 'l',
76
34
  subagent: 'l',
@@ -83,7 +41,6 @@ export const LANE: Record<MarkKind, Lane> = {
83
41
  bookmark: 'f',
84
42
  }
85
43
 
86
- /** Who wins the colour when marks merge. */
87
44
  export const LOUDNESS: Record<MarkKind, number> = {
88
45
  approval: 7,
89
46
  error: 6,
@@ -108,48 +65,40 @@ export const KIND_NAME: Record<MarkKind, string> = {
108
65
  bookmark: 'bookmark',
109
66
  }
110
67
 
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
68
  export const MIN_MARK = 2
114
69
 
115
- export const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
116
- `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
70
+ export function doneLine(turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string {
71
+ return `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
72
+ }
117
73
 
118
74
  export function excerpt(item: TranscriptItem): string {
119
75
  switch (item.kind) {
120
76
  case 'user':
121
77
  case 'assistant_text':
122
78
  case 'thinking':
123
- case 'notice':
79
+ case 'notice': {
124
80
  return item.text
125
- case 'tool_call':
81
+ }
82
+ case 'tool_call': {
126
83
  return `${item.name}(${toolInputPreview(item.input)})`
127
- case 'turn_result':
84
+ }
85
+ case 'turn_result': {
128
86
  return doneLine(item)
129
- case 'file_delivered':
87
+ }
88
+ case 'file_delivered': {
130
89
  return item.path
131
- default:
90
+ }
91
+ default: {
132
92
  return ''
93
+ }
133
94
  }
134
95
  }
135
96
 
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
97
  type Segment = { response?: number; turn?: number; failed?: boolean }
142
98
 
143
99
  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
100
  frameParentId?: string
150
- /** Bookmarked item indices. Paint only — the store is the client's. */
151
101
  bookmarks?: readonly number[]
152
- /** The catch-up boundary's item index, when the recap is spliced in. */
153
102
  recapItemIndex?: number
154
103
  }
155
104
 
@@ -158,14 +107,12 @@ export function buildMarks(
158
107
  { frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
159
108
  ): Mark[] {
160
109
  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
110
  const subagentParents = new Set<string>()
166
111
  for (const item of items) {
167
112
  const parent = parentOf(item)
168
- if (parent !== undefined) subagentParents.add(parent)
113
+ if (parent !== undefined) {
114
+ subagentParents.add(parent)
115
+ }
169
116
  }
170
117
  let segment: Segment = {}
171
118
  const closeSegment = () => {
@@ -180,16 +127,9 @@ export function buildMarks(
180
127
  segment = {}
181
128
  }
182
129
  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
130
  if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
188
131
  marks.push({ kind: 'subagent', itemIndex: index })
189
132
  }
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
133
  if (item.kind === 'user' && parentOf(item) === frameParentId) {
194
134
  closeSegment()
195
135
  marks.push({ kind: 'user', itemIndex: index })
@@ -200,11 +140,6 @@ export function buildMarks(
200
140
  } else if (item.kind === 'notice' && item.level === 'error') {
201
141
  marks.push({ kind: 'error', itemIndex: index })
202
142
  } 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
143
  item.kind === 'tool_call' &&
209
144
  parentOf(item) === frameParentId &&
210
145
  (item.status === 'failed' || item.result?.isError === true)
@@ -218,20 +153,18 @@ export function buildMarks(
218
153
  segment.response = index
219
154
  }
220
155
  })
221
- // A history that ends mid-segment still has an answer in it.
222
156
  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 })
157
+ for (const index of bookmarks) {
158
+ if (index >= 0 && index < items.length) {
159
+ marks.push({ kind: 'bookmark', itemIndex: index })
160
+ }
161
+ }
162
+ if (recapItemIndex !== undefined) {
163
+ marks.push({ kind: 'recap', itemIndex: recapItemIndex })
164
+ }
226
165
  return marks
227
166
  }
228
167
 
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
168
  export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
236
169
  const count = Math.max(1, itemCount)
237
170
  const h = Math.max(MIN_MARK, Math.round(railH / count))
@@ -248,11 +181,11 @@ export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: n
248
181
  list.sort((a, b) => a.y - b.y)
249
182
  let current: Cluster | null = null
250
183
  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
184
  if (current && y <= current.y + current.h + 1) {
254
185
  current.h = Math.max(current.h, y + h - current.y)
255
- if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) current.kind = mark.kind
186
+ if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {
187
+ current.kind = mark.kind
188
+ }
256
189
  current.marks.push({ mark, y })
257
190
  } else {
258
191
  current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
@@ -263,9 +196,6 @@ export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: n
263
196
  return clusters
264
197
  }
265
198
 
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
199
  export function approvalCluster(railH: number): Cluster {
270
200
  return {
271
201
  lane: LANE.approval,
@@ -1,15 +1,11 @@
1
1
  import type { SessionStatus } from '@workerdeck/protocol'
2
2
  import type { BadgeProps } from '../ui/Badge.tsx'
3
3
 
4
- export const STATUS_META: Record<
5
- SessionStatus,
6
- { label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }
7
- > = {
4
+ export const STATUS_META: Record<SessionStatus, { label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }> = {
8
5
  starting: { label: 'Starting', variant: 'info', busy: true },
9
6
  running: { label: 'Running', variant: 'info', busy: true },
10
7
  awaiting_approval: { label: 'Needs approval', variant: 'warning', busy: true },
11
8
  idle: { label: 'Idle', variant: 'success', busy: false },
12
- // Waiting on a deferred execution: the run is alive but nothing is burning.
13
9
  parked: { label: 'Parked', variant: 'accent', busy: false },
14
10
  failed: { label: 'Failed', variant: 'danger', busy: false },
15
11
  closed: { label: 'Closed', variant: 'neutral', busy: false },
@@ -1,36 +1,14 @@
1
1
  import { createContext, useContext, type ReactNode } from 'react'
2
2
 
3
- /**
4
- * How a row gets back the part of a tool result the replay did not send.
5
- *
6
- * A **context**, not a prop chain, for the same reason the variant is one: the
7
- * rows that need it are drawn by `terminalBlocks` and by the cards theme's
8
- * `ToolCallCard`, several layers below whoever holds the session, and a row
9
- * composed by hand should get the same behaviour without threading a callback
10
- * through everything in between.
11
- *
12
- * The default is a no-op resolving `false`, which is exactly right for every
13
- * surface that never asked for truncation (the playground, a fixture, an
14
- * embedder rendering rows by hand): `result.truncated` is only ever set by a
15
- * replay a renderer opted into, so a row that has no fetcher also has no head to
16
- * complete. A press still opens the row; it simply has everything already.
17
- */
18
3
  export type ToolResultFetcher = (toolUseId: string) => Promise<boolean>
19
4
 
20
- const FetchContext = createContext<ToolResultFetcher>(async () => false)
5
+ const noop: ToolResultFetcher = async () => false
6
+ const FetchContext = createContext<ToolResultFetcher>(noop)
21
7
 
22
- export function ToolResultFetchProvider({
23
- value,
24
- children,
25
- }: {
26
- value: ToolResultFetcher | undefined
27
- children: ReactNode
28
- }) {
8
+ export function ToolResultFetchProvider({ value, children }: { value: ToolResultFetcher | undefined; children: ReactNode }) {
29
9
  return <FetchContext.Provider value={value ?? noop}>{children}</FetchContext.Provider>
30
10
  }
31
11
 
32
- const noop: ToolResultFetcher = async () => false
33
-
34
12
  export function useToolResultFetcher(): ToolResultFetcher {
35
13
  return useContext(FetchContext)
36
14
  }