@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
@@ -0,0 +1,155 @@
1
+ import type { MessageAttachment } from '@workerdeck/protocol'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
5
+ import { FileCard } from './FileCard.tsx'
6
+ import { Message, MessageContent } from './Message.tsx'
7
+ import { PromptTokenText } from './PromptTokenText.tsx'
8
+ import { Reasoning } from './Reasoning.tsx'
9
+ import { Response } from './Response.tsx'
10
+ import { ToolCallCard } from './ToolCallCard.tsx'
11
+ import { Row } from '../terminal/row.tsx'
12
+ import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
13
+
14
+ function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
15
+ return (
16
+ <div data-slot="turn-result" className="py-1">
17
+ <div className="flex items-center gap-2">
18
+ <div className="h-px flex-1 bg-border" />
19
+ <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
20
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
21
+ </span>
22
+ <div className="h-px flex-1 bg-border" />
23
+ </div>
24
+ {item.errors?.length ? (
25
+ <ul className="mt-1 flex flex-col gap-0.5 text-center">
26
+ {item.errors.map((message, index) => (
27
+ <li key={index} className="text-label break-words text-danger">
28
+ {message}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ ) : null}
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
38
+ return (
39
+ <div
40
+ data-slot="notice"
41
+ className={cn(
42
+ 'rounded-md border px-3 py-2 text-body-sm',
43
+ item.level === 'error' ? 'border-transparent bg-danger-bg text-danger' : 'border-border bg-surface text-fg-3',
44
+ )}
45
+ >
46
+ {item.text}
47
+ </div>
48
+ )
49
+ }
50
+
51
+ /** The item-view switch: one transcript item, drawn for the active variant. */
52
+ export function TranscriptItemView({
53
+ item,
54
+ fileUrl,
55
+ attachmentUrl,
56
+ hostImage,
57
+ terminal,
58
+ }: {
59
+ item: TranscriptItem
60
+ fileUrl?: (path: string) => string
61
+ attachmentUrl?: (attachmentId: string) => string
62
+ hostImage?: (path: string) => Promise<string | undefined>
63
+ terminal?: boolean
64
+ }) {
65
+ if (terminal) {
66
+ return <TerminalItemView item={item} fileUrl={fileUrl} />
67
+ }
68
+ switch (item.kind) {
69
+ case 'user': {
70
+ return (
71
+ <Message from="user">
72
+ {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
73
+ {item.text ? (
74
+ <MessageContent>
75
+ <PromptTokenText text={item.text} />
76
+ </MessageContent>
77
+ ) : null}
78
+ </Message>
79
+ )
80
+ }
81
+ case 'assistant_text': {
82
+ return (
83
+ <Message from="assistant">
84
+ <MessageContent>
85
+ <Response streaming={item.streaming}>{item.text}</Response>
86
+ </MessageContent>
87
+ </Message>
88
+ )
89
+ }
90
+ case 'thinking': {
91
+ return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
92
+ }
93
+ case 'tool_call': {
94
+ return <ToolCallCard item={item} hostImage={hostImage} />
95
+ }
96
+ case 'turn_result': {
97
+ return <TurnResultRow item={item} />
98
+ }
99
+ case 'notice': {
100
+ return <NoticeRow item={item} />
101
+ }
102
+ case 'file_delivered': {
103
+ return <FileCard item={item} href={fileUrl?.(item.path)} />
104
+ }
105
+ default: {
106
+ return null
107
+ }
108
+ }
109
+ }
110
+
111
+ export function RecapRow({ line, since, terminal }: { line: string; since?: number; terminal?: boolean }) {
112
+ const away = since === undefined ? undefined : formatRelativeTime(since)
113
+ const text = away ? `${line} · last here ${away}` : line
114
+
115
+ if (terminal) {
116
+ return (
117
+ <div data-slot="recap">
118
+ <Row glyph="※" glyphTone="faint" tone="faint">
119
+ recap: {text}
120
+ </Row>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ return (
126
+ <div data-slot="recap" className="flex items-center gap-2 py-1">
127
+ <div className="h-px flex-1 bg-border" />
128
+ <span className="font-mono text-label text-fg-3">※ recap: {text}</span>
129
+ <div className="h-px flex-1 bg-border" />
130
+ </div>
131
+ )
132
+ }
133
+
134
+ function SentAttachments({
135
+ attachments,
136
+ attachmentUrl,
137
+ }: {
138
+ attachments: MessageAttachment[]
139
+ attachmentUrl?: (attachmentId: string) => string
140
+ }) {
141
+ return (
142
+ <div className="mb-1 flex flex-wrap justify-start gap-1.5">
143
+ {attachments.map((attachment) => {
144
+ const href = attachmentUrl?.(attachment.id)
145
+ return attachment.mediaType.startsWith('image/') && href ? (
146
+ <img key={attachment.id} src={href} alt={attachment.name} className="size-20 rounded-md border border-border object-cover" />
147
+ ) : (
148
+ <span key={attachment.id} className="rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3">
149
+ {attachment.name}
150
+ </span>
151
+ )
152
+ })}
153
+ </div>
154
+ )
155
+ }
@@ -0,0 +1,418 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { defaultRangeExtractor, useVirtualizer, type Range } from '@tanstack/react-virtual'
4
+ import { useStickToBottomContext } from 'use-stick-to-bottom'
5
+ import type { PermissionRequest } from '@workerdeck/protocol'
6
+ import type { TranscriptItem } from '@workerdeck/react'
7
+ import { cn } from '../../lib/utils.ts'
8
+ import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
9
+ import { ToolRunRow } from '../terminal/items.tsx'
10
+ import { parentOf } from '../terminal/blocks.ts'
11
+ import { briefPx, estimateBlockPx } from '../terminal/height.ts'
12
+ import { TerminalScrubber } from '../terminal/scrubber.tsx'
13
+ import { Scrubber } from './Scrubber.tsx'
14
+ import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
15
+ import { useHeightEpoch } from './use-height-epoch.ts'
16
+ import { useTranscriptJumps } from './use-transcript-jumps.ts'
17
+ import { BriefRow, TaskRow } from '../terminal/TerminalTranscript.tsx'
18
+ import { RecapRow, TranscriptItemView } from './TranscriptItemView.tsx'
19
+
20
+ function read(boundary: number | undefined, index: number): boolean {
21
+ return boundary !== undefined && index < boundary
22
+ }
23
+
24
+ function nestedClass(item: TranscriptItem, frameParentId?: string): string | undefined {
25
+ const parent = 'parentToolUseId' in item ? item.parentToolUseId : undefined
26
+ const nested = parent != null && parent !== frameParentId
27
+ return nested ? 'border-l-2 border-border pl-3' : undefined
28
+ }
29
+
30
+ function promptHeadText(item: Extract<TranscriptItem, { kind: 'user' }>): string {
31
+ return item.text || (item.attachments ?? []).map((attachment) => attachment.name).join(', ')
32
+ }
33
+
34
+ function StickyPromptLane({
35
+ top,
36
+ height,
37
+ gapClass,
38
+ gapPx,
39
+ terminal,
40
+ scrollRoot,
41
+ index,
42
+ measureRef,
43
+ head,
44
+ content,
45
+ }: {
46
+ top: number
47
+ height: number
48
+ gapClass?: string | false
49
+ gapPx: number
50
+ terminal: boolean
51
+ scrollRoot: HTMLElement | null
52
+ index: number
53
+ measureRef: (element: HTMLDivElement | null) => void
54
+ head: ReactNode
55
+ content: ReactNode
56
+ }) {
57
+ const headRef = useRef<HTMLDivElement | null>(null)
58
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
59
+ useEffect(() => {
60
+ const headElement = headRef.current
61
+ const sentinel = sentinelRef.current
62
+ if (!headElement || !sentinel || !scrollRoot) {
63
+ return
64
+ }
65
+ const evaluate = () => {
66
+ const stuck = sentinel.getBoundingClientRect().top < scrollRoot.getBoundingClientRect().top
67
+ headElement.toggleAttribute('data-stuck', stuck)
68
+ }
69
+ evaluate()
70
+ scrollRoot.addEventListener('scroll', evaluate, { passive: true })
71
+ return () => scrollRoot.removeEventListener('scroll', evaluate)
72
+ }, [scrollRoot, top, height, gapClass])
73
+ return (
74
+ <div data-sticky-lane={terminal ? 'terminal' : 'cards'} className="absolute inset-x-0" style={{ top, height }}>
75
+ <div data-sticky-headlane="" aria-hidden>
76
+ <div ref={headRef} data-sticky-head="" className={(terminal && gapClass) || undefined}>
77
+ {head}
78
+ </div>
79
+ </div>
80
+ <div
81
+ ref={sentinelRef}
82
+ aria-hidden
83
+ className="absolute left-0 w-px"
84
+ style={{ top: gapClass ? (terminal ? 'var(--term-line)' : gapPx) : 0, height: 1 }}
85
+ />
86
+ <div ref={measureRef} data-index={index} className={gapClass || undefined}>
87
+ {content}
88
+ </div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * The virtualized row lane and its scroll-ownership regime. Two things want to write
95
+ * `scrollTop` — the follow spring and the virtualizer's size-change corrections — and they are
96
+ * split by regime here: pinned, corrections are suppressed outright; escaped, the virtualizer
97
+ * corrects so the scrollback holds still (GOTCHAS "The transcript is virtualized").
98
+ */
99
+ export function TranscriptRows({
100
+ rows,
101
+ boundary,
102
+ since,
103
+ terminal,
104
+ replaying,
105
+ stickyPrompt,
106
+ frameParentId,
107
+ onOpenSubagent,
108
+ gap,
109
+ fontSize,
110
+ lineHeight,
111
+ items,
112
+ pendingApprovals,
113
+ scrubber,
114
+ bookmarks,
115
+ affordances,
116
+ fileUrl,
117
+ attachmentUrl,
118
+ hostImage,
119
+ jumpToRecapRef,
120
+ repinRef,
121
+ reveal,
122
+ }: {
123
+ rows: TranscriptRow[]
124
+ boundary: number | undefined
125
+ since: number | undefined
126
+ terminal: boolean
127
+ replaying: boolean
128
+ stickyPrompt: boolean
129
+ gap: { className?: string; px: number }
130
+ fontSize?: number
131
+ lineHeight?: number
132
+ items: readonly TranscriptItem[]
133
+ pendingApprovals: readonly PermissionRequest[]
134
+ scrubber?: boolean
135
+ bookmarks?: readonly string[]
136
+ frameParentId?: string
137
+ onOpenSubagent?: (toolUseId: string) => void
138
+ affordances?: TerminalAffordances | boolean
139
+ fileUrl?: (path: string) => string
140
+ attachmentUrl?: (attachmentId: string) => string
141
+ hostImage?: (path: string) => Promise<string | undefined>
142
+ jumpToRecapRef?: RefObject<(() => void) | null>
143
+ repinRef?: RefObject<(() => void) | null>
144
+ reveal?: { toolUseId: string; nonce: number }
145
+ }) {
146
+ const stick = useStickToBottomContext()
147
+ const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
148
+ const promptRows = useMemo(
149
+ () => rows.flatMap((row, index) => ('item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined ? [index] : [])),
150
+ [rows],
151
+ )
152
+ const pinRef = useRef<{ enabled: boolean; promptRows: readonly number[] }>({
153
+ enabled: false,
154
+ promptRows: [],
155
+ })
156
+ pinRef.current = { enabled: stickyPrompt, promptRows }
157
+ useEffect(() => {
158
+ // A passive effect, not a layout one: the scroll element belongs to an ancestor, so at mount its ref is not attached yet and a layout effect reads null.
159
+ setScrollElement(stick.scrollRef.current)
160
+ }, [stick.scrollRef])
161
+
162
+ useEffect(() => {
163
+ if (!scrollElement) {
164
+ return
165
+ }
166
+ let last = scrollElement.clientHeight
167
+ const observer = new ResizeObserver(() => {
168
+ const height = scrollElement.clientHeight
169
+ if (height === last) {
170
+ return
171
+ }
172
+ last = height
173
+ if (stick.state.isAtBottom) {
174
+ void stick.scrollToBottom('instant')
175
+ }
176
+ })
177
+ observer.observe(scrollElement)
178
+ return () => observer.disconnect()
179
+ }, [scrollElement, stick])
180
+
181
+ const wasReplaying = useRef(replaying)
182
+ useLayoutEffect(() => {
183
+ const was = wasReplaying.current
184
+ wasReplaying.current = replaying
185
+ if (!was || replaying) {
186
+ return
187
+ }
188
+ if (!stick.state.isAtBottom) {
189
+ return
190
+ }
191
+ stick.state.scrollTop = stick.state.calculatedTargetScrollTop
192
+ }, [replaying, stick])
193
+
194
+ const rowsRef = useRef<HTMLDivElement | null>(null)
195
+ const epoch = useHeightEpoch({ terminal, fontSize, lineHeight, rowsRef })
196
+
197
+ const virtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
198
+ count: rows.length,
199
+ // The return type is annotated because the body reads `virtualizer` back: without it the inference is circular and tsgo gives up on the hook.
200
+ getScrollElement: (): HTMLElement | null => {
201
+ if (scrollElement && virtualizer.scrollElement !== scrollElement) {
202
+ // On adopting a scroll element the virtualizer replays its remembered offset into the DOM, so syncing here keeps a remembered 0 from yanking a pinned transcript to the top.
203
+ virtualizer.scrollOffset = scrollElement.scrollTop
204
+ }
205
+ return scrollElement
206
+ },
207
+ estimateSize: (index) => {
208
+ if (terminal && epoch) {
209
+ const row = rows[index]
210
+ const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
211
+ if (row && 'text' in row && row.key === 'brief') {
212
+ return briefPx(row.text, epoch) + gapPx
213
+ }
214
+ if (row && !('line' in row)) {
215
+ return estimateBlockPx(row, epoch) + gapPx
216
+ }
217
+ return epoch.line + gapPx
218
+ }
219
+ return (terminal ? 36 : 100) + gap.px
220
+ },
221
+ overscan: 8,
222
+ getItemKey: (index) => rows[index].key,
223
+ rangeExtractor: useCallback((range: Range) => {
224
+ const indexes = new Set(defaultRangeExtractor(range))
225
+ const { enabled, promptRows: prompts } = pinRef.current
226
+ if (enabled) {
227
+ const offset = virtualizer.scrollOffset ?? 0
228
+ let pinned = -1
229
+ for (const index of prompts) {
230
+ if ((virtualizer.measurementsCache[index]?.start ?? Infinity) <= offset) {
231
+ pinned = index
232
+ } else {
233
+ break
234
+ }
235
+ }
236
+ if (pinned >= 0) {
237
+ indexes.add(pinned)
238
+ }
239
+ }
240
+ return [...indexes].sort((a, b) => a - b)
241
+ }, []),
242
+ useFlushSync: true,
243
+ })
244
+ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
245
+ if (stick.state.isAtBottom) {
246
+ return false
247
+ }
248
+ const fold = (instance.scrollOffset ?? 0) + instance.scrollAdjustments
249
+ return instance.itemSizeCache.has(item.key) ? item.end <= fold && instance.scrollDirection !== 'backward' : item.start < fold
250
+ }
251
+
252
+ useEffect(() => {
253
+ if (!terminal || !epoch) {
254
+ return
255
+ }
256
+ virtualizer.measure()
257
+ const container = rowsRef.current
258
+ if (!container) {
259
+ return
260
+ }
261
+ virtualizer.getTotalSize()
262
+ for (const element of container.querySelectorAll<HTMLElement>('[data-index]')) {
263
+ const index = Number(element.getAttribute('data-index'))
264
+ if (Number.isInteger(index) && index >= 0) {
265
+ virtualizer.resizeItem(index, element.getBoundingClientRect().height)
266
+ }
267
+ }
268
+ }, [terminal, epoch, virtualizer])
269
+
270
+ const jumpToRow = useTranscriptJumps({
271
+ rows,
272
+ terminal,
273
+ stickyPrompt,
274
+ epoch,
275
+ promptRows,
276
+ scrollElement,
277
+ rowsRef,
278
+ virtualizer,
279
+ stick,
280
+ jumpToRecapRef,
281
+ repinRef,
282
+ })
283
+
284
+ const revealNonce = reveal?.nonce
285
+ const revealId = reveal?.toolUseId
286
+ useEffect(() => {
287
+ if (revealId === undefined) {
288
+ return
289
+ }
290
+ const itemIndex = items.findIndex((item) => item.kind === 'tool_call' && item.id === revealId)
291
+ if (itemIndex < 0) {
292
+ return
293
+ }
294
+ jumpToRow(rowIndexForItem(rows, itemIndex), 'start')
295
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the nonce IS the trigger
296
+ }, [revealNonce])
297
+
298
+ const scrubInteractive = resolveAffordances(affordances).hover
299
+ // Hosts hand bookmarks over as item ids (stable across replays); the mark model positions by
300
+ // index, so the translation lives here where the items are. Ids not in this transcript (another
301
+ // frame, a truncated replay) simply draw nothing.
302
+ const bookmarkIndexes = useMemo(() => {
303
+ if (!bookmarks?.length) {
304
+ return []
305
+ }
306
+ const indexById = new Map(items.map((item, index) => [item.id, index]))
307
+ return bookmarks.map((id) => indexById.get(id)).filter((index): index is number => index !== undefined)
308
+ }, [bookmarks, items])
309
+ const recapIndex = rows.findIndex((row) => row.key === 'recap')
310
+ const recapRow = recapIndex >= 0 ? { rowIndex: recapIndex, label: (rows[recapIndex] as { line: string }).line } : undefined
311
+ useEffect(() => {
312
+ if (!terminal || !scrubber || !scrubInteractive || !scrollElement) {
313
+ return
314
+ }
315
+ scrollElement.setAttribute('data-term-scrubber-host', '')
316
+ return () => scrollElement.removeAttribute('data-term-scrubber-host')
317
+ }, [terminal, scrubber, scrubInteractive, scrollElement])
318
+
319
+ const measurements = virtualizer.measurementsCache
320
+
321
+ return (
322
+ <div ref={rowsRef} data-slot="transcript-rows" className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
323
+ {virtualizer.getVirtualItems().map((virtualRow) => {
324
+ const row = rows[virtualRow.index]
325
+ const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
326
+ const content =
327
+ 'run' in row ? (
328
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
329
+ <ToolRunRow items={row.run} />
330
+ </div>
331
+ ) : 'item' in row ? (
332
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
333
+ <TranscriptItemView
334
+ item={row.item}
335
+ fileUrl={fileUrl}
336
+ attachmentUrl={attachmentUrl}
337
+ hostImage={hostImage}
338
+ terminal={terminal}
339
+ />
340
+ </div>
341
+ ) : 'text' in row && row.key === 'brief' ? (
342
+ <BriefRow text={row.text} terminal={terminal} />
343
+ ) : 'line' in row ? (
344
+ <RecapRow line={row.line} since={since} terminal={terminal} />
345
+ ) : (
346
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
347
+ <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
348
+ </div>
349
+ )
350
+ if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
351
+ const next = promptRows.find((index) => index > virtualRow.index)
352
+ const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
353
+ return (
354
+ <StickyPromptLane
355
+ key={row.key}
356
+ top={virtualRow.start}
357
+ height={Math.max(laneEnd - virtualRow.start, 0)}
358
+ gapClass={gapClass}
359
+ gapPx={gap.px}
360
+ terminal={terminal}
361
+ scrollRoot={scrollElement}
362
+ index={virtualRow.index}
363
+ measureRef={virtualizer.measureElement}
364
+ head={terminal ? content : promptHeadText(row.item)}
365
+ content={content}
366
+ />
367
+ )
368
+ }
369
+ return (
370
+ <div
371
+ key={row.key}
372
+ ref={virtualizer.measureElement}
373
+ data-index={virtualRow.index}
374
+ className={cn('absolute inset-x-0 top-0', gapClass)}
375
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
376
+ >
377
+ {content}
378
+ </div>
379
+ )
380
+ })}
381
+ {scrubber && scrollElement?.parentElement
382
+ ? createPortal(
383
+ terminal ? (
384
+ <TerminalScrubber
385
+ items={items}
386
+ pendingApprovals={pendingApprovals}
387
+ recapRow={recapRow}
388
+ bookmarks={bookmarkIndexes}
389
+ frameParentId={frameParentId}
390
+ rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
391
+ positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
392
+ offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
393
+ sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
394
+ totalSize={virtualizer.getTotalSize()}
395
+ scrollOffset={virtualizer.scrollOffset ?? 0}
396
+ viewportH={virtualizer.scrollRect?.height ?? 0}
397
+ onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
398
+ interactive={scrubInteractive}
399
+ fontSize={fontSize}
400
+ lineHeight={lineHeight}
401
+ />
402
+ ) : (
403
+ <Scrubber
404
+ items={items}
405
+ pendingApprovals={pendingApprovals}
406
+ recapItemIndex={recapIndex >= 0 ? boundary : undefined}
407
+ bookmarks={bookmarkIndexes}
408
+ frameParentId={frameParentId}
409
+ interactive={scrubInteractive}
410
+ onJumpToItem={(itemIndex) => jumpToRow(rowIndexForItem(rows, itemIndex), 'start')}
411
+ />
412
+ ),
413
+ scrollElement.parentElement,
414
+ )
415
+ : null}
416
+ </div>
417
+ )
418
+ }
@@ -5,34 +5,16 @@ import { formatAgoPrecise, formatCost } from '../../lib/format.ts'
5
5
  import { UsageMeters, useMinuteClock } from './UsageMeters.tsx'
6
6
 
7
7
  export interface UsageDialogProps {
8
- /**
9
- * Windows in reading order — session, weekly, then per-model weeklies.
10
- *
11
- * `updatedAt`/`inferredReset` ride each window because the readings no longer
12
- * share one clock: the panel merges the gateway's per-profile state over this
13
- * session's, so a `five_hour` learned from a sibling session two minutes ago
14
- * can sit beside a `seven_day` this session last heard about yesterday.
15
- * `inferredReset` marks the ones the *gateway* zeroed because their own reset
16
- * time passed with nothing newer — a floor, not a report.
17
- */
18
8
  rateLimits: UsageWindowRow[]
19
- /** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
20
9
  subscriptionType?: string
21
10
  engine: ProfileEngine
22
11
  totalCostUsd: number
23
- /** When the *freshest* of the windows was read, for the footer line. Readings
24
- * arrive one per turn at best (and for an idle session, not at all), so a
25
- * stale one is normal and worth saying out loud. */
26
12
  updatedAt?: number
27
13
  open: boolean
28
14
  onOpenChange: (open: boolean) => void
29
15
  className?: string
30
16
  }
31
17
 
32
- /**
33
- * The session's view of the plan: every window as a meter (see
34
- * {@link UsageMeters}), the plan tier, and what this session itself has cost.
35
- */
36
18
  export function UsageDialog({
37
19
  rateLimits,
38
20
  subscriptionType,
@@ -48,13 +30,11 @@ export function UsageDialog({
48
30
  <Dialog open={open} onOpenChange={onOpenChange}>
49
31
  <DialogContent className={className}>
50
32
  <DialogHeader
51
- title='Usage'
33
+ title="Usage"
52
34
  description={engine === 'claude' ? 'Claude Code' : engine}
53
35
  actions={
54
- // The CLI reports a tier ('max'), never the multiplier a
55
- // subscription page shows — so this says "Max" and stops there.
56
36
  subscriptionType ? (
57
- <Badge variant='accent' className='mt-0.5 shrink-0 capitalize'>
37
+ <Badge variant="accent" className="mt-0.5 shrink-0 capitalize">
58
38
  {subscriptionType}
59
39
  </Badge>
60
40
  ) : null
@@ -62,7 +42,7 @@ export function UsageDialog({
62
42
  />
63
43
  <DialogBody>
64
44
  {rateLimits.length === 0 ? (
65
- <p className='py-6 text-center text-body-sm text-fg-4'>
45
+ <p className="py-6 text-center text-body-sm text-fg-4">
66
46
  {engine === 'claude'
67
47
  ? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
68
48
  : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
@@ -70,13 +50,11 @@ export function UsageDialog({
70
50
  ) : (
71
51
  <UsageMeters windows={rateLimits} now={now} />
72
52
  )}
73
- <div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
74
- <span className='text-label text-fg-3'>This session has cost</span>
75
- <span className='font-mono text-body-sm text-fg-1'>{formatCost(totalCostUsd)}</span>
53
+ <div className="mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3">
54
+ <span className="text-label text-fg-3">This session has cost</span>
55
+ <span className="font-mono text-body-sm text-fg-1">{formatCost(totalCostUsd)}</span>
76
56
  </div>
77
- {updatedAt ? (
78
- <p className='mt-2 text-label text-fg-4'>Updated {formatAgoPrecise(updatedAt, now)}</p>
79
- ) : null}
57
+ {updatedAt ? <p className="mt-2 text-label text-fg-4">Updated {formatAgoPrecise(updatedAt, now)}</p> : null}
80
58
  </DialogBody>
81
59
  </DialogContent>
82
60
  </Dialog>