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