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