@workerdeck/ui 0.22.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +80 -24
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +9 -124
- package/build/workspace.mjs +13 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +4 -6
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +345 -923
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +108 -215
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +136 -19
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-13l25ubU.d.mts +0 -609
- package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,214 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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'
|
|
13
|
-
import { useStickToBottomContext } from 'use-stick-to-bottom'
|
|
14
|
-
import type { MessageAttachment, PermissionRequest } from '@workerdeck/protocol'
|
|
15
|
-
import { recapLine, summarizeSince, type TranscriptItem, type TranscriptState } from '@workerdeck/react'
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
import { recapLine, summarizeSince, type TranscriptState } from '@workerdeck/react'
|
|
16
3
|
import { cn } from '../../lib/utils.ts'
|
|
17
|
-
import { formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
|
|
18
4
|
import { Conversation, ConversationContent, ConversationScrollButton } from './Conversation.tsx'
|
|
19
|
-
import { FileCard } from './FileCard.tsx'
|
|
20
5
|
import { Loader } from './Loader.tsx'
|
|
21
|
-
import { Message, MessageContent } from './Message.tsx'
|
|
22
|
-
import { PromptTokenText } from './PromptTokenText.tsx'
|
|
23
|
-
import { Reasoning } from './Reasoning.tsx'
|
|
24
|
-
import { Response } from './Response.tsx'
|
|
25
6
|
import { SessionEmptyState } from './SessionEmptyState.tsx'
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { briefPx, estimateBlockPx } from '../terminal/height.ts'
|
|
33
|
-
import { TerminalScrubber } from '../terminal/scrubber.tsx'
|
|
34
|
-
import { Scrubber } from './Scrubber.tsx'
|
|
35
|
-
import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
|
|
36
|
-
import { useHeightEpoch } from './use-height-epoch.ts'
|
|
37
|
-
import { useTranscriptJumps } from './use-transcript-jumps.ts'
|
|
38
|
-
import { Row } from '../terminal/row.tsx'
|
|
7
|
+
import { type TerminalAffordances } from '../terminal/affordances.tsx'
|
|
8
|
+
import { WorkingRow } from '../terminal/items.tsx'
|
|
9
|
+
import { subagentItems, terminalBlocks, type ToolCallItem } from '../terminal/blocks.ts'
|
|
10
|
+
import { taskBrief, taskBusy } from '../terminal/tool-run.ts'
|
|
11
|
+
import { type TranscriptRow } from './transcript-rows.ts'
|
|
12
|
+
import { TranscriptRows } from './TranscriptRows.tsx'
|
|
39
13
|
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
ROW_GAP,
|
|
43
|
-
TranscriptVariantProvider,
|
|
44
|
-
type TranscriptDensity,
|
|
45
|
-
type TranscriptVariant,
|
|
46
|
-
} from './transcript-variant.tsx'
|
|
14
|
+
import { ROW_GAP, TranscriptVariantProvider, type TranscriptDensity, type TranscriptVariant } from './transcript-variant.tsx'
|
|
47
15
|
|
|
48
|
-
function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
49
|
-
return (
|
|
50
|
-
<div data-slot='turn-result' className='py-1'>
|
|
51
|
-
<div className='flex items-center gap-2'>
|
|
52
|
-
<div className='h-px flex-1 bg-border' />
|
|
53
|
-
<span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
|
|
54
|
-
{item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
|
|
55
|
-
{formatCost(item.totalCostUsd)}
|
|
56
|
-
</span>
|
|
57
|
-
<div className='h-px flex-1 bg-border' />
|
|
58
|
-
</div>
|
|
59
|
-
{/* A failed turn's reasons are the whole point of the row — dropping them
|
|
60
|
-
leaves "error_during_execution" and nothing to act on. */}
|
|
61
|
-
{item.errors?.length ? (
|
|
62
|
-
<ul className='mt-1 flex flex-col gap-0.5 text-center'>
|
|
63
|
-
{item.errors.map((message, index) => (
|
|
64
|
-
<li key={index} className='text-label break-words text-danger'>
|
|
65
|
-
{message}
|
|
66
|
-
</li>
|
|
67
|
-
))}
|
|
68
|
-
</ul>
|
|
69
|
-
) : null}
|
|
70
|
-
</div>
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
|
|
75
|
-
return (
|
|
76
|
-
<div
|
|
77
|
-
data-slot='notice'
|
|
78
|
-
className={cn(
|
|
79
|
-
'rounded-md border px-3 py-2 text-body-sm',
|
|
80
|
-
item.level === 'error'
|
|
81
|
-
? 'border-transparent bg-danger-bg text-danger'
|
|
82
|
-
: 'border-border bg-surface text-fg-3',
|
|
83
|
-
)}>
|
|
84
|
-
{item.text}
|
|
85
|
-
</div>
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function TranscriptItemView({
|
|
90
|
-
item,
|
|
91
|
-
fileUrl,
|
|
92
|
-
attachmentUrl,
|
|
93
|
-
hostImage,
|
|
94
|
-
terminal,
|
|
95
|
-
}: {
|
|
96
|
-
item: TranscriptItem
|
|
97
|
-
fileUrl?: (path: string) => string
|
|
98
|
-
attachmentUrl?: (attachmentId: string) => string
|
|
99
|
-
hostImage?: (path: string) => Promise<string | undefined>
|
|
100
|
-
terminal?: boolean
|
|
101
|
-
}) {
|
|
102
|
-
// The terminal theme is a renderer, not a branch: it draws every kind itself,
|
|
103
|
-
// so the switch below is never reached under it.
|
|
104
|
-
if (terminal) return <TerminalItemView item={item} fileUrl={fileUrl} />
|
|
105
|
-
switch (item.kind) {
|
|
106
|
-
case 'user':
|
|
107
|
-
return (
|
|
108
|
-
<Message from='user'>
|
|
109
|
-
{item.attachments?.length ? (
|
|
110
|
-
<SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} />
|
|
111
|
-
) : null}
|
|
112
|
-
{/* A photo can be the whole message — an empty bubble under it says nothing. */}
|
|
113
|
-
{item.text ? (
|
|
114
|
-
<MessageContent>
|
|
115
|
-
<PromptTokenText text={item.text} />
|
|
116
|
-
</MessageContent>
|
|
117
|
-
) : null}
|
|
118
|
-
</Message>
|
|
119
|
-
)
|
|
120
|
-
case 'assistant_text':
|
|
121
|
-
return (
|
|
122
|
-
<Message from='assistant'>
|
|
123
|
-
<MessageContent>
|
|
124
|
-
<Response streaming={item.streaming}>{item.text}</Response>
|
|
125
|
-
</MessageContent>
|
|
126
|
-
</Message>
|
|
127
|
-
)
|
|
128
|
-
case 'thinking':
|
|
129
|
-
return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
|
|
130
|
-
case 'tool_call':
|
|
131
|
-
return <ToolCallCard item={item} hostImage={hostImage} />
|
|
132
|
-
case 'turn_result':
|
|
133
|
-
return <TurnResultRow item={item} />
|
|
134
|
-
case 'notice':
|
|
135
|
-
return <NoticeRow item={item} />
|
|
136
|
-
case 'file_delivered':
|
|
137
|
-
return <FileCard item={item} href={fileUrl?.(item.path)} />
|
|
138
|
-
default:
|
|
139
|
-
return null
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* The "you were here" line: what happened since the session was last looked at,
|
|
145
|
-
* counted from the transcript rather than written by the model (see
|
|
146
|
-
* `summarizeSince`). Everything above it is dimmed while catch-up is on, so the
|
|
147
|
-
* boundary is visible from anywhere in the scrollback, not just at the mark.
|
|
148
|
-
*
|
|
149
|
-
* The line itself is computed in `Transcript`, not here: the recap is a row of
|
|
150
|
-
* the virtual list, and a boundary with nothing to say must contribute no row
|
|
151
|
-
* at all rather than an empty slot that still costs its gap.
|
|
152
|
-
*/
|
|
153
|
-
function RecapRow({ line, since, terminal }: { line: string; since?: number; terminal?: boolean }) {
|
|
154
|
-
const away = since === undefined ? undefined : formatRelativeTime(since)
|
|
155
|
-
const text = away ? `${line} · last here ${away}` : line
|
|
156
|
-
|
|
157
|
-
// Under the terminal theme it is a Row like everything else, and that is not
|
|
158
|
-
// cosmetic: the cards markup measures 42px against an 18px line, so it was the
|
|
159
|
-
// one row in the transcript sitting off the grid — and it shifted *every row
|
|
160
|
-
// below it* by the remainder, which is precisely the failure the whole-multiple
|
|
161
|
-
// rule exists to prevent. It was invisible until a fixture carried a recap
|
|
162
|
-
// splice. Being a real row also makes it exactly computable, so the height
|
|
163
|
-
// calculator loses its last estimated constant.
|
|
164
|
-
if (terminal) {
|
|
165
|
-
return (
|
|
166
|
-
<div data-slot='recap'>
|
|
167
|
-
<Row glyph='※' glyphTone='faint' tone='faint'>
|
|
168
|
-
recap: {text}
|
|
169
|
-
</Row>
|
|
170
|
-
</div>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return (
|
|
175
|
-
<div data-slot='recap' className='flex items-center gap-2 py-1'>
|
|
176
|
-
<div className='h-px flex-1 bg-border' />
|
|
177
|
-
<span className='font-mono text-label text-fg-3'>※ recap: {text}</span>
|
|
178
|
-
<div className='h-px flex-1 bg-border' />
|
|
179
|
-
</div>
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* **Nothing on this surface animates its scroll position.** VS Code does not —
|
|
185
|
-
* click its editor scrollbar and it jumps — and neither does a terminal, which
|
|
186
|
-
* is the article this transcript is drawing. Every travel a reader ever
|
|
187
|
-
* complained about here was an animation we asked for.
|
|
188
|
-
*
|
|
189
|
-
* What used to be here was `useSettled`: a latch deciding smooth-vs-instant for
|
|
190
|
-
* the follow spring, with a quiet window, a "silence before the first row does
|
|
191
|
-
* not count" guard and a live-status gate — all of it apparatus for a
|
|
192
|
-
* smooth-scroll bug (the attach replays hundreds of rows, and animating that
|
|
193
|
-
* turned opening a session into a several-second journey). With no smooth mode
|
|
194
|
-
* left there is nothing for it to decide, so the whole thing is gone rather
|
|
195
|
-
* than pinned to `false`.
|
|
196
|
-
*
|
|
197
|
-
* The two remaining writers of `scrollTop` — the follow spring and the
|
|
198
|
-
* virtualizer's size-change correction — are unchanged and still split by
|
|
199
|
-
* regime; `Conversation` itself is now hardwired to `instant` on both `initial`
|
|
200
|
-
* and `resize`.
|
|
201
|
-
*/
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* When the current run began — the clock the working line counts from.
|
|
205
|
-
*
|
|
206
|
-
* Taken here rather than in the loader because the loader comes and goes within
|
|
207
|
-
* a single turn (it hides the moment text starts streaming), and a clock that
|
|
208
|
-
* restarted every time the model paused for a tool would be measuring the wrong
|
|
209
|
-
* thing. Held as state, not a ref: the value has to survive re-renders and reset
|
|
210
|
-
* exactly once, when the session goes back to idle.
|
|
211
|
-
*/
|
|
212
16
|
function useRunStart(status: TranscriptState['status']): number | undefined {
|
|
213
17
|
const running = status === 'running' || status === 'starting'
|
|
214
18
|
const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
|
|
@@ -218,59 +22,23 @@ function useRunStart(status: TranscriptState['status']): number | undefined {
|
|
|
218
22
|
return running ? startedAt : undefined
|
|
219
23
|
}
|
|
220
24
|
|
|
221
|
-
/** Should the "waiting for output" loader show? Only while running with no in-flight
|
|
222
|
-
* streamed content at the tail of the transcript. */
|
|
223
25
|
function showLoader(state: TranscriptState): boolean {
|
|
224
|
-
if (state.status !== 'running' && state.status !== 'starting')
|
|
26
|
+
if (state.status !== 'running' && state.status !== 'starting') {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
225
29
|
const last = state.items.at(-1)
|
|
226
|
-
if (!last)
|
|
227
|
-
|
|
228
|
-
|
|
30
|
+
if (!last) {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
if (last.kind === 'assistant_text' && last.streaming) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (last.kind === 'thinking' && last.id === 'streaming-thinking') {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
229
39
|
return last.kind !== 'turn_result' || state.status === 'running'
|
|
230
40
|
}
|
|
231
41
|
|
|
232
|
-
/** Files sent with a message: thumbnails for images, named chips for the rest.
|
|
233
|
-
* References only — the bytes are fetched from the gateway. */
|
|
234
|
-
function SentAttachments({
|
|
235
|
-
attachments,
|
|
236
|
-
attachmentUrl,
|
|
237
|
-
}: {
|
|
238
|
-
attachments: MessageAttachment[]
|
|
239
|
-
attachmentUrl?: (attachmentId: string) => string
|
|
240
|
-
}) {
|
|
241
|
-
return (
|
|
242
|
-
<div className='mb-1 flex flex-wrap justify-start gap-1.5'>
|
|
243
|
-
{attachments.map((attachment) => {
|
|
244
|
-
const href = attachmentUrl?.(attachment.id)
|
|
245
|
-
return attachment.mediaType.startsWith('image/') && href ? (
|
|
246
|
-
<img
|
|
247
|
-
key={attachment.id}
|
|
248
|
-
src={href}
|
|
249
|
-
alt={attachment.name}
|
|
250
|
-
className='size-20 rounded-md border border-border object-cover'
|
|
251
|
-
/>
|
|
252
|
-
) : (
|
|
253
|
-
<span
|
|
254
|
-
key={attachment.id}
|
|
255
|
-
className='rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3'>
|
|
256
|
-
{attachment.name}
|
|
257
|
-
</span>
|
|
258
|
-
)
|
|
259
|
-
})}
|
|
260
|
-
</div>
|
|
261
|
-
)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* The terminal theme's root, when that is the variant, and a passthrough when it
|
|
266
|
-
* is not.
|
|
267
|
-
*
|
|
268
|
-
* It has to live *inside* the scroller's content element rather than around the
|
|
269
|
-
* whole `Conversation`: `--term-line` and `1ch` are inherited, and the rows are
|
|
270
|
-
* rendered by the virtualizer several levels down. Wrapping the scroller instead
|
|
271
|
-
* would work identically for the cells and break the full-bleed bands, whose
|
|
272
|
-
* negative margins are measured against this element's padding.
|
|
273
|
-
*/
|
|
274
42
|
function TerminalShell({
|
|
275
43
|
active,
|
|
276
44
|
fontSize,
|
|
@@ -284,892 +52,39 @@ function TerminalShell({
|
|
|
284
52
|
affordances?: TerminalAffordances | boolean
|
|
285
53
|
children: ReactNode
|
|
286
54
|
}) {
|
|
287
|
-
if (!active)
|
|
55
|
+
if (!active) {
|
|
56
|
+
return <>{children}</>
|
|
57
|
+
}
|
|
288
58
|
return (
|
|
289
|
-
<TerminalSurface
|
|
290
|
-
fontSize={fontSize}
|
|
291
|
-
lineHeight={lineHeight}
|
|
292
|
-
affordances={affordances}
|
|
293
|
-
bleed='1ch'
|
|
294
|
-
className='term-transcript'>
|
|
59
|
+
<TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={affordances} bleed="1ch" className="term-transcript">
|
|
295
60
|
{children}
|
|
296
61
|
</TerminalSurface>
|
|
297
62
|
)
|
|
298
63
|
}
|
|
299
64
|
|
|
300
|
-
/** Already read: present, legible, and visibly behind you. */
|
|
301
|
-
function read(boundary: number | undefined, index: number): boolean {
|
|
302
|
-
return boundary !== undefined && index < boundary
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
|
|
306
|
-
* behind a rule, so a Task's own output reads as belonging to the tool call
|
|
307
|
-
* above it rather than as the main thread carrying on.
|
|
308
|
-
*
|
|
309
|
-
* **Except inside that sub-agent's own frame**, where those same items are the
|
|
310
|
-
* top level and there is no main thread to be an aside from — stepping every row
|
|
311
|
-
* in would draw a rule down the whole surface saying "this happened somewhere
|
|
312
|
-
* else" about the only thing on screen. */
|
|
313
|
-
function nestedClass(item: TranscriptItem, frameParentId?: string): string | undefined {
|
|
314
|
-
const parent = 'parentToolUseId' in item ? item.parentToolUseId : undefined
|
|
315
|
-
const nested = parent != null && parent !== frameParentId
|
|
316
|
-
return nested ? 'border-l-2 border-border pl-3' : undefined
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// The virtual row model — what a row *is*, the spacing rule, and the
|
|
320
|
-
// item-index → row-index mapping — lives in `transcript-rows.ts`; re-exported
|
|
321
|
-
// here because this file is where consumers have always found them.
|
|
322
65
|
export { rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
|
|
323
66
|
|
|
324
|
-
/** The cards head's one line: the prompt as a plain run of text. Deliberately
|
|
325
|
-
* not the Message component — a proportional card clipped by height is a
|
|
326
|
-
* sliced bubble, and un-styling one from CSS is a fight (see theme.css's
|
|
327
|
-
* sticky-prompt block for the other half of this decision). Prompt syntax
|
|
328
|
-
* stays literal — a 28px bar is a reminder of what was asked, not a rendering
|
|
329
|
-
* surface — and newlines collapse under the bar's `nowrap`. Attachment-only
|
|
330
|
-
* prompts fall back to the attachments' names so the bar is never blank. */
|
|
331
|
-
function promptHeadText(item: Extract<TranscriptItem, { kind: 'user' }>): string {
|
|
332
|
-
return item.text || (item.attachments ?? []).map((attachment) => attachment.name).join(', ')
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* A prompt row's sticky lane — the strip spanning its turn, leading with the
|
|
337
|
-
* one-line pinned **head** (see the pinned-prompt comment in
|
|
338
|
-
* {@link TranscriptRows}).
|
|
339
|
-
*
|
|
340
|
-
* The head's content is the variant's: the terminal passes the row again
|
|
341
|
-
* (clipping it to one line is exact under a monospace grid), cards passes the
|
|
342
|
-
* prompt as plain text for theme.css to draw as a compact bar. Only the
|
|
343
|
-
* terminal head carries the row's gap class — its stuck geometry parks that
|
|
344
|
-
* padding above the viewport edge so the visible line docks at zero
|
|
345
|
-
* (terminal.css); the cards bar never aligns with the row in flow, so the gap
|
|
346
|
-
* stays off it entirely and the 1st prompt and the Nth share one geometry.
|
|
347
|
-
*
|
|
348
|
-
* The head starts `visibility: hidden` and shows only while actually stuck —
|
|
349
|
-
* an overlay that is visible in flow would sit on the real row's first line
|
|
350
|
-
* and swallow its selection highlight, which reads as "the first line cannot
|
|
351
|
-
* be selected". CSS cannot ask "am I stuck?", so a 1px sentinel at the head's
|
|
352
|
-
* engage threshold (the line's own y) answers it: sentinel above the
|
|
353
|
-
* scrollport top → stuck, read by a passive scroll listener. It was an
|
|
354
|
-
* IntersectionObserver once, for the "no per-scroll work" purity — and that
|
|
355
|
-
* was a real bug: IO is edge-triggered, and an *instant* jump (the open-at-
|
|
356
|
-
* bottom pin, `jumpToRow`, a reveal) teleports the sentinel from below the
|
|
357
|
-
* viewport to above it between two observations — ratio 0 → 0, no threshold
|
|
358
|
-
* crossed, `isIntersecting` unchanged — so no entry is ever queued and the
|
|
359
|
-
* flag strands, in whichever direction the jump left it (observed: a session
|
|
360
|
-
* opened at the bottom, its prompt bar missing; the stale-true twin paints a
|
|
361
|
-
* bar over the real bubble). The flag needs level-triggered truth. The cost
|
|
362
|
-
* is two rect reads per scroll event per mounted lane — layout is clean
|
|
363
|
-
* during scrolling, and the pin itself is still the compositor's; only the
|
|
364
|
-
* bar's visibility rides the listener.
|
|
365
|
-
*/
|
|
366
|
-
function StickyPromptLane({
|
|
367
|
-
top,
|
|
368
|
-
height,
|
|
369
|
-
gapClass,
|
|
370
|
-
gapPx,
|
|
371
|
-
terminal,
|
|
372
|
-
scrollRoot,
|
|
373
|
-
index,
|
|
374
|
-
measureRef,
|
|
375
|
-
head,
|
|
376
|
-
content,
|
|
377
|
-
}: {
|
|
378
|
-
top: number
|
|
379
|
-
height: number
|
|
380
|
-
gapClass?: string | false
|
|
381
|
-
/** The gap's size in px (`ROW_GAP[...].px`) — the cards sentinel offset,
|
|
382
|
-
* where the terminal uses `--term-line` (exact against its own cell where a
|
|
383
|
-
* px constant would drift). */
|
|
384
|
-
gapPx: number
|
|
385
|
-
terminal: boolean
|
|
386
|
-
scrollRoot: HTMLElement | null
|
|
387
|
-
index: number
|
|
388
|
-
measureRef: (element: HTMLDivElement | null) => void
|
|
389
|
-
/** What the pinned head shows — see the component comment. */
|
|
390
|
-
head: ReactNode
|
|
391
|
-
content: ReactNode
|
|
392
|
-
}) {
|
|
393
|
-
const headRef = useRef<HTMLDivElement | null>(null)
|
|
394
|
-
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
395
|
-
// `top`/`height`/`gapClass` are deps because they move the sentinel without
|
|
396
|
-
// any scroll: a measurement refinement re-positions the lane under a still
|
|
397
|
-
// scroller, and only a fresh evaluation notices. Scroll covers the rest —
|
|
398
|
-
// including programmatic jumps, which fire a scroll event like any other
|
|
399
|
-
// write of `scrollTop`.
|
|
400
|
-
useEffect(() => {
|
|
401
|
-
const headElement = headRef.current
|
|
402
|
-
const sentinel = sentinelRef.current
|
|
403
|
-
if (!headElement || !sentinel || !scrollRoot) return
|
|
404
|
-
const evaluate = () => {
|
|
405
|
-
// Strictly above the scrollport's top edge — at exact equality the real
|
|
406
|
-
// row's first line is itself flush with the top, and the head must not
|
|
407
|
-
// cover it.
|
|
408
|
-
const stuck =
|
|
409
|
-
sentinel.getBoundingClientRect().top < scrollRoot.getBoundingClientRect().top
|
|
410
|
-
headElement.toggleAttribute('data-stuck', stuck)
|
|
411
|
-
}
|
|
412
|
-
evaluate()
|
|
413
|
-
scrollRoot.addEventListener('scroll', evaluate, { passive: true })
|
|
414
|
-
return () => scrollRoot.removeEventListener('scroll', evaluate)
|
|
415
|
-
}, [scrollRoot, top, height, gapClass])
|
|
416
|
-
return (
|
|
417
|
-
// The attribute VALUE is the styling seam: terminal.css matches the bare
|
|
418
|
-
// attribute under its `[data-terminal]` scope, theme.css keys the cards
|
|
419
|
-
// bar on `[data-sticky-lane='cards']` — no `:not()` acrobatics either side.
|
|
420
|
-
<div
|
|
421
|
-
data-sticky-lane={terminal ? 'terminal' : 'cards'}
|
|
422
|
-
className='absolute inset-x-0'
|
|
423
|
-
style={{ top, height }}>
|
|
424
|
-
{/* The head rides in its own absolutely positioned sub-lane rather than
|
|
425
|
-
in flow with a cancelled footprint: sticky confinement clamps the
|
|
426
|
-
*margin* box, and a negative bottom margin shrinks that box to zero
|
|
427
|
-
height — the head then overshoots the lane's end by its own height,
|
|
428
|
-
which put two pinned prompts on screen at once during the handoff.
|
|
429
|
-
Out of flow, the border box is what gets clamped, and the push-off
|
|
430
|
-
lands exactly at the lane's bottom edge. */}
|
|
431
|
-
<div data-sticky-headlane='' aria-hidden>
|
|
432
|
-
<div ref={headRef} data-sticky-head='' className={(terminal && gapClass) || undefined}>
|
|
433
|
-
{head}
|
|
434
|
-
</div>
|
|
435
|
-
</div>
|
|
436
|
-
<div
|
|
437
|
-
ref={sentinelRef}
|
|
438
|
-
aria-hidden
|
|
439
|
-
className='absolute left-0 w-px'
|
|
440
|
-
style={{ top: gapClass ? (terminal ? 'var(--term-line)' : gapPx) : 0, height: 1 }}
|
|
441
|
-
/>
|
|
442
|
-
<div ref={measureRef} data-index={index} className={gapClass || undefined}>
|
|
443
|
-
{content}
|
|
444
|
-
</div>
|
|
445
|
-
</div>
|
|
446
|
-
)
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* The virtualized row window. Only rows near the viewport are mounted, so
|
|
451
|
-
* opening a thousand-row session commits a screenful of DOM rather than the
|
|
452
|
-
* whole session in one go.
|
|
453
|
-
*
|
|
454
|
-
* The delicate part is that two parties want to write `scrollTop`.
|
|
455
|
-
* `use-stick-to-bottom` owns *following*: its spring animates toward the
|
|
456
|
-
* bottom whenever the content grows, recomputing the target from the live
|
|
457
|
-
* `scrollHeight` every frame. The virtualizer wants to *correct* `scrollTop`
|
|
458
|
-
* whenever a row measures differently from its estimate, so that what the
|
|
459
|
-
* reader is looking at doesn't shift. Letting both write at once is jitter at
|
|
460
|
-
* best — and a correction that moves the viewport *up* (a row measuring
|
|
461
|
-
* smaller than estimated) reads as a user scroll to the follow logic, which
|
|
462
|
-
* escapes the bottom lock mid-stream.
|
|
463
|
-
*
|
|
464
|
-
* The resolution is that pinned and escaped are different regimes:
|
|
465
|
-
*
|
|
466
|
-
* - Pinned (`state.isAtBottom`), corrections are suppressed. "At the bottom"
|
|
467
|
-
* is the entire scroll position; the offsets of rows above are moot. The
|
|
468
|
-
* height change a mismeasured row causes still re-fires the follow spring —
|
|
469
|
-
* through the same content resize observer that follows streaming — so the
|
|
470
|
-
* view converges on the bottom through the one writer that knows how to
|
|
471
|
-
* distinguish its own writes from the user's.
|
|
472
|
-
* - Escaped, the virtualizer corrects (its stock rules, restated below) so
|
|
473
|
-
* the scrollback holds still under the reader while rows above it measure.
|
|
474
|
-
*
|
|
475
|
-
* `anchorTo`/`followOnAppend` stay at their defaults for the same reason:
|
|
476
|
-
* the virtualizer must never become a second follow implementation.
|
|
477
|
-
*
|
|
478
|
-
* Accepted costs of virtualizing, so they are a decision and not a surprise:
|
|
479
|
-
* browser find-in-page and select-all reach only the mounted rows, and a row's
|
|
480
|
-
* transient UI state (an expanded tool card, an opened reasoning block) resets
|
|
481
|
-
* once the row scrolls far enough away to unmount.
|
|
482
|
-
*/
|
|
483
|
-
function TranscriptRows({
|
|
484
|
-
rows,
|
|
485
|
-
boundary,
|
|
486
|
-
since,
|
|
487
|
-
terminal,
|
|
488
|
-
replaying,
|
|
489
|
-
stickyPrompt,
|
|
490
|
-
frameParentId,
|
|
491
|
-
onOpenSubagent,
|
|
492
|
-
gap,
|
|
493
|
-
fontSize,
|
|
494
|
-
lineHeight,
|
|
495
|
-
items,
|
|
496
|
-
pendingApprovals,
|
|
497
|
-
scrubber,
|
|
498
|
-
scrubberMarks,
|
|
499
|
-
affordances,
|
|
500
|
-
fileUrl,
|
|
501
|
-
attachmentUrl,
|
|
502
|
-
hostImage,
|
|
503
|
-
jumpToRecapRef,
|
|
504
|
-
repinRef,
|
|
505
|
-
reveal,
|
|
506
|
-
}: {
|
|
507
|
-
rows: TranscriptRow[]
|
|
508
|
-
boundary: number | undefined
|
|
509
|
-
since: number | undefined
|
|
510
|
-
/** The terminal theme draws its own rows; see {@link TranscriptItemView}. */
|
|
511
|
-
terminal: boolean
|
|
512
|
-
/** The replay hold (see {@link TranscriptProps.replaying}) — read here only
|
|
513
|
-
* for its falling edge, which needs a pre-paint pin. */
|
|
514
|
-
replaying: boolean
|
|
515
|
-
/** Pin the prompt of the turn being read to the top of the scroller. */
|
|
516
|
-
stickyPrompt: boolean
|
|
517
|
-
/** The inter-row gap for this variant and density (`ROW_GAP`). */
|
|
518
|
-
gap: { className?: string; px: number }
|
|
519
|
-
/** The terminal cell, when the host set one — only read as a signal that the
|
|
520
|
-
* height epoch below must re-measure; the epoch's numbers come from the DOM. */
|
|
521
|
-
fontSize?: number
|
|
522
|
-
lineHeight?: number
|
|
523
|
-
/** The transcript items — the scrubber's marks and peeks render from these,
|
|
524
|
-
* never from the DOM (the row a mark points at is usually unmounted). */
|
|
525
|
-
items: readonly TranscriptItem[]
|
|
526
|
-
pendingApprovals: readonly PermissionRequest[]
|
|
527
|
-
/** Mount the overview-ruler rail (terminal theme only). */
|
|
528
|
-
scrubber?: boolean
|
|
529
|
-
scrubberMarks?: readonly number[]
|
|
530
|
-
/** Set when these rows are a sub-agent's frame — the id everything here was
|
|
531
|
-
* produced inside. Only `nestedClass` needs it: inside the frame those items
|
|
532
|
-
* are the top level and must not be stepped in. */
|
|
533
|
-
frameParentId?: string
|
|
534
|
-
onOpenSubagent?: (toolUseId: string) => void
|
|
535
|
-
affordances?: TerminalAffordances | boolean
|
|
536
|
-
fileUrl?: (path: string) => string
|
|
537
|
-
attachmentUrl?: (attachmentId: string) => string
|
|
538
|
-
hostImage?: (path: string) => Promise<string | undefined>
|
|
539
|
-
jumpToRecapRef?: RefObject<(() => void) | null>
|
|
540
|
-
repinRef?: RefObject<(() => void) | null>
|
|
541
|
-
reveal?: { toolUseId: string; nonce: number }
|
|
542
|
-
}) {
|
|
543
|
-
const stick = useStickToBottomContext()
|
|
544
|
-
// The scroll element belongs to an ancestor — `StickToBottom.Content`
|
|
545
|
-
// renders it — so when this component's layout effects run at mount, the
|
|
546
|
-
// ancestor's ref is not attached yet and the virtualizer would see null.
|
|
547
|
-
// Handing it over from a passive effect (which runs after every ref in the
|
|
548
|
-
// commit is attached) also guarantees the re-render that lets the
|
|
549
|
-
// virtualizer adopt it promptly: without one, a transcript short enough to
|
|
550
|
-
// never fire a scroll event could sit renderless indefinitely.
|
|
551
|
-
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
|
|
552
|
-
// Which rows *could* be the pinned prompt. Recomputed only when the row list
|
|
553
|
-
// changes, so the per-scroll work below is a walk over prompts rather than
|
|
554
|
-
// over the transcript.
|
|
555
|
-
const promptRows = useMemo(
|
|
556
|
-
// Top-level prompts only: a subagent's brief is a `user` item too, and one
|
|
557
|
-
// that escaped absorption (an orphan) must not become the pinned prompt —
|
|
558
|
-
// it is not what the answer on screen belongs to.
|
|
559
|
-
() =>
|
|
560
|
-
rows.flatMap((row, index) =>
|
|
561
|
-
'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined
|
|
562
|
-
? [index]
|
|
563
|
-
: [],
|
|
564
|
-
),
|
|
565
|
-
[rows],
|
|
566
|
-
)
|
|
567
|
-
// The pinned row must stay mounted even when it is far above the window, so
|
|
568
|
-
// it is forced into the virtual range — see `rangeExtractor` below, which
|
|
569
|
-
// reads these refs rather than closing over render values: it is called from
|
|
570
|
-
// inside the virtualizer's own range pass, where a closure would be a stale
|
|
571
|
-
// render's.
|
|
572
|
-
const pinRef = useRef<{ enabled: boolean; promptRows: readonly number[] }>({
|
|
573
|
-
enabled: false,
|
|
574
|
-
promptRows: [],
|
|
575
|
-
})
|
|
576
|
-
pinRef.current = { enabled: stickyPrompt, promptRows }
|
|
577
|
-
useEffect(() => {
|
|
578
|
-
setScrollElement(stick.scrollRef.current)
|
|
579
|
-
}, [stick.scrollRef])
|
|
580
|
-
|
|
581
|
-
// A composer that grows steals a line from the transcript.
|
|
582
|
-
//
|
|
583
|
-
// The composer and the transcript are siblings in the panel's flex column, so
|
|
584
|
-
// typing a newline shrinks this scroller by one line. "At the bottom" is a
|
|
585
|
-
// `scrollTop`, and that number stops meaning the bottom the moment the
|
|
586
|
-
// viewport changes height — so the last row, the one you were reading, slides
|
|
587
|
-
// under the fold as you type.
|
|
588
|
-
//
|
|
589
|
-
// `use-stick-to-bottom` cannot catch this: its ResizeObserver observes the
|
|
590
|
-
// **content** element (`.observe(content)` in its `useStickToBottom`), and
|
|
591
|
-
// here the content is unchanged and the *scroller* moved. Nor does the browser
|
|
592
|
-
// help — scrollTop is untouched, so no scroll event fires and nothing
|
|
593
|
-
// recomputes. Hence an observer of our own, on the scroller's own box.
|
|
594
|
-
//
|
|
595
|
-
// The guard is the whole feature: re-pin **only when already pinned**, or
|
|
596
|
-
// every newline yanks a reader who had deliberately scrolled up. That state
|
|
597
|
-
// lives in here, which is why this is in `Transcript` and not `SessionPanel`.
|
|
598
|
-
// Reading `stick.state.isAtBottom` (the library's live object, not the
|
|
599
|
-
// rendered boolean) is safe precisely because of the paragraph above: no
|
|
600
|
-
// scroll event fired, so the flag still holds the pre-resize answer.
|
|
601
|
-
//
|
|
602
|
-
// This is not a third writer of `scrollTop` — it presses the follow spring's
|
|
603
|
-
// own button, instantly, which is what the spring would have done had it
|
|
604
|
-
// noticed. The pinned-suppresses-corrections regime below is untouched.
|
|
605
|
-
useEffect(() => {
|
|
606
|
-
if (!scrollElement) return
|
|
607
|
-
let last = scrollElement.clientHeight
|
|
608
|
-
const observer = new ResizeObserver(() => {
|
|
609
|
-
const height = scrollElement.clientHeight
|
|
610
|
-
if (height === last) return
|
|
611
|
-
last = height
|
|
612
|
-
if (stick.state.isAtBottom) void stick.scrollToBottom('instant')
|
|
613
|
-
})
|
|
614
|
-
observer.observe(scrollElement)
|
|
615
|
-
return () => observer.disconnect()
|
|
616
|
-
}, [scrollElement, stick])
|
|
617
|
-
|
|
618
|
-
// The replay hold's reveal must paint already at the bottom, and the follow
|
|
619
|
-
// spring cannot make that true: even `scrollToBottom('instant')` defers its
|
|
620
|
-
// write behind a `requestAnimationFrame`, one frame after the reveal's paint
|
|
621
|
-
// — so the first visible frame showed the tail a burst shy of the bottom and
|
|
622
|
-
// then hopped (measured: revealTop 33037 against final 34459 on the 600-row
|
|
623
|
-
// fixture). A layout effect runs after the commit that removed the hold's
|
|
624
|
-
// visibility and before its paint, so this write lands in the very frame the
|
|
625
|
-
// transcript appears. It presses the library's own `state.scrollTop` setter
|
|
626
|
-
// — which records the write in `ignoreScrollToTop`, so the scroll handler
|
|
627
|
-
// knows it for its own — not a raw `scrollTop`, and only on the hold's
|
|
628
|
-
// falling edge, only while pinned; it is the pin's own move made a frame
|
|
629
|
-
// early, not a third writer.
|
|
630
|
-
const wasReplaying = useRef(replaying)
|
|
631
|
-
useLayoutEffect(() => {
|
|
632
|
-
const was = wasReplaying.current
|
|
633
|
-
wasReplaying.current = replaying
|
|
634
|
-
if (!was || replaying) return
|
|
635
|
-
if (!stick.state.isAtBottom) return
|
|
636
|
-
stick.state.scrollTop = stick.state.calculatedTargetScrollTop
|
|
637
|
-
}, [replaying, stick])
|
|
638
|
-
|
|
639
|
-
// The height epoch — see `use-height-epoch.ts`. Owned here because this
|
|
640
|
-
// component owns the virtualizer the heights feed.
|
|
641
|
-
const rowsRef = useRef<HTMLDivElement | null>(null)
|
|
642
|
-
const epoch = useHeightEpoch({ terminal, fontSize, lineHeight, rowsRef })
|
|
643
|
-
|
|
644
|
-
const virtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
|
|
645
|
-
count: rows.length,
|
|
646
|
-
// On adopting a scroll element the virtualizer *replays* its remembered
|
|
647
|
-
// offset into the DOM (that is how `initialOffset` is honored). By then
|
|
648
|
-
// the pin has usually scrolled the element already, so the remembered 0
|
|
649
|
-
// would yank a pinned transcript silently back to the top — the observed
|
|
650
|
-
// race was exactly that, the follow jump at ~180ms and the replay undoing
|
|
651
|
-
// it at ~590ms. Syncing the remembered offset to the DOM at the adoption
|
|
652
|
-
// boundary makes every replay a no-op; from then on the scroll observer
|
|
653
|
-
// owns the field.
|
|
654
|
-
// Annotated because the body reads `virtualizer` back: without a declared
|
|
655
|
-
// return type the inference is circular and tsgo gives up on the whole hook.
|
|
656
|
-
getScrollElement: (): HTMLElement | null => {
|
|
657
|
-
if (scrollElement && virtualizer.scrollElement !== scrollElement) {
|
|
658
|
-
virtualizer.scrollOffset = scrollElement.scrollTop
|
|
659
|
-
}
|
|
660
|
-
return scrollElement
|
|
661
|
-
},
|
|
662
|
-
// Estimates only shape the scrollbar and the span of never-mounted rows; a
|
|
663
|
-
// measurement replaces them the moment a row mounts. Under the terminal
|
|
664
|
-
// theme they are *computed* (`terminal/height.ts`): the theme's one line
|
|
665
|
-
// height and one cell make a row's height derivable from its item, so the
|
|
666
|
-
// scrollbar is honest before rows mount and `scrollToIndex` sums real
|
|
667
|
-
// sizes instead of accumulating error over unmeasured spans. The gap rides
|
|
668
|
-
// the same estimate because it is real height on the same measured element
|
|
669
|
-
// — one line, decided per pair by `gapBefore`, exactly as the renderer
|
|
670
|
-
// applies the class. The recap row is a Row here too, so it is one line and
|
|
671
|
-
// exact — it used to be the cards markup, which measured 42px against an
|
|
672
|
-
// 18px line and pushed every row below it off the grid.
|
|
673
|
-
// Cards keep the flat constant: they vary too much for any constant to be
|
|
674
|
-
// right, so it is merely the order of magnitude — and the calculator has
|
|
675
|
-
// no claim there (padding scales, borders, a proportional face).
|
|
676
|
-
estimateSize: (index) => {
|
|
677
|
-
if (terminal && epoch) {
|
|
678
|
-
const row = rows[index]
|
|
679
|
-
const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
|
|
680
|
-
if (row && 'text' in row && row.key === 'brief')
|
|
681
|
-
// Collapsed by default and clipped to BRIEF_LINES, so its height is
|
|
682
|
-
// known before it mounts — the same discipline the task row keeps by
|
|
683
|
-
// always being collapsed when unmounted. Expanding is local state on
|
|
684
|
-
// a mounted row, which the virtualizer re-measures.
|
|
685
|
-
return briefPx(row.text, epoch) + gapPx
|
|
686
|
-
if (row && !('line' in row))
|
|
687
|
-
return estimateBlockPx(row, epoch) + gapPx
|
|
688
|
-
return epoch.line + gapPx // recap: one Row, one line
|
|
689
|
-
}
|
|
690
|
-
return (terminal ? 36 : 100) + gap.px
|
|
691
|
-
},
|
|
692
|
-
overscan: 8,
|
|
693
|
-
getItemKey: (index) => rows[index].key,
|
|
694
|
-
// The sticky row, forced into the range. This is the virtualizer's own
|
|
695
|
-
// sticky-header seam: without it the pinned prompt's lane unmounts the
|
|
696
|
-
// moment it leaves the window, which is exactly when it is doing its job.
|
|
697
|
-
// The active prompt — the last one starting at or above the fold — is
|
|
698
|
-
// computed *here*, from the instance's own offset, rather than in the
|
|
699
|
-
// render body: the range pass runs before the render that would refresh a
|
|
700
|
-
// ref, so a value computed outside this callback is one scroll event
|
|
701
|
-
// stale, and a long programmatic jump would leave the pinned row unmounted
|
|
702
|
-
// until the next scroll. (`virtualizer` is safe to close over: the hook
|
|
703
|
-
// returns one stable instance for the component's lifetime.)
|
|
704
|
-
rangeExtractor: useCallback((range: Range) => {
|
|
705
|
-
const indexes = new Set(defaultRangeExtractor(range))
|
|
706
|
-
const { enabled, promptRows: prompts } = pinRef.current
|
|
707
|
-
if (enabled) {
|
|
708
|
-
const offset = virtualizer.scrollOffset ?? 0
|
|
709
|
-
let pinned = -1
|
|
710
|
-
for (const index of prompts) {
|
|
711
|
-
if ((virtualizer.measurementsCache[index]?.start ?? Infinity) <= offset) pinned = index
|
|
712
|
-
else break
|
|
713
|
-
}
|
|
714
|
-
if (pinned >= 0) indexes.add(pinned)
|
|
715
|
-
}
|
|
716
|
-
return [...indexes].sort((a, b) => a - b)
|
|
717
|
-
}, []),
|
|
718
|
-
// Explicit, and left at the default, because the obvious cleanup here is
|
|
719
|
-
// wrong. A correction fires from `measureElement`'s ref callback — inside
|
|
720
|
-
// React's commit — so the core's synchronous flush draws a "flushSync was
|
|
721
|
-
// called from inside a lifecycle method" error, which in an embedder's
|
|
722
|
-
// console reads as our bug. Turning it off silences that and costs
|
|
723
|
-
// anchoring: over the same six-step walk up through unmeasured rows, `true`
|
|
724
|
-
// holds the scrollback to the pixel and `false` let one step slide 112px
|
|
725
|
-
// under the reader. Holding still is the entire point of the correction,
|
|
726
|
-
// so the noise stays.
|
|
727
|
-
useFlushSync: true,
|
|
728
|
-
// The list sits below the content div's top padding, so row offsets are a
|
|
729
|
-
// few px shy of true scroll offsets. `scrollMargin` exists for exactly
|
|
730
|
-
// this, but feeding it means measuring the spacer's offsetTop into state;
|
|
731
|
-
// the error is smaller than one overscan row, so it is deliberately left.
|
|
732
|
-
})
|
|
733
|
-
// (See the component comment.) Supplying the callback at all replaces the
|
|
734
|
-
// core's default rules, so the escaped branch restates them: on a first
|
|
735
|
-
// measurement compensate any row whose top sits above the fold; on a
|
|
736
|
-
// re-measurement only a row entirely above it — a row *spanning* the fold
|
|
737
|
-
// grows below the reader's anchor point — and never while scrolling up,
|
|
738
|
-
// where corrections cascade.
|
|
739
|
-
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
|
740
|
-
if (stick.state.isAtBottom) return false
|
|
741
|
-
const fold = (instance.scrollOffset ?? 0) + instance.scrollAdjustments
|
|
742
|
-
return instance.itemSizeCache.has(item.key)
|
|
743
|
-
? item.end <= fold && instance.scrollDirection !== 'backward'
|
|
744
|
-
: item.start < fold
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// A new epoch means every remembered size is against the wrong metrics —
|
|
748
|
-
// the *measurements* included, which were taken at the old width. Dropping
|
|
749
|
-
// both together is the whole point: better estimates layered under
|
|
750
|
-
// stale-width measurements would be worse than either alone.
|
|
751
|
-
//
|
|
752
|
-
// The second half is not optional: `measure()` clears the size cache and a
|
|
753
|
-
// row re-enters it only when its ResizeObserver fires — which needs a *size
|
|
754
|
-
// change*. A mounted row whose height happens to survive the width change
|
|
755
|
-
// (short lines that never rewrap) would keep its estimate forever, and
|
|
756
|
-
// wherever the estimate is off the transcript grows a phantom tail — 2,052px
|
|
757
|
-
// of scrollable nothing after one sidebar toggle, on a real session. So the
|
|
758
|
-
// mounted rows are fed straight back in.
|
|
759
|
-
useEffect(() => {
|
|
760
|
-
if (!terminal || !epoch) return
|
|
761
|
-
virtualizer.measure()
|
|
762
|
-
const container = rowsRef.current
|
|
763
|
-
if (!container) return
|
|
764
|
-
// Two sharp edges in the re-feed, both learned the hard way. Order:
|
|
765
|
-
// `resizeItem` diffs a measure against `measurementsCache`, and straight
|
|
766
|
-
// after `measure()` that array is still the pre-wipe one — an unchanged
|
|
767
|
-
// row diffs to zero against its own old measurement and the write is
|
|
768
|
-
// skipped; recomputing first (any measurement read does it) rebuilds the
|
|
769
|
-
// array from estimates, so the diff is real again. And `resizeItem`
|
|
770
|
-
// directly, not `measureElement(element)`: the latter is gated on the
|
|
771
|
-
// scroll state and silently drops a measure that lands while a scroll is
|
|
772
|
-
// still hot — which a resize's own scroll anchoring makes routine.
|
|
773
|
-
virtualizer.getTotalSize()
|
|
774
|
-
for (const element of container.querySelectorAll<HTMLElement>('[data-index]')) {
|
|
775
|
-
const index = Number(element.getAttribute('data-index'))
|
|
776
|
-
if (Number.isInteger(index) && index >= 0)
|
|
777
|
-
virtualizer.resizeItem(index, element.getBoundingClientRect().height)
|
|
778
|
-
}
|
|
779
|
-
}, [terminal, epoch, virtualizer])
|
|
780
|
-
|
|
781
|
-
// The jump machinery — the aim loop, the catch-up strip's jump, the re-pin
|
|
782
|
-
// — lives in `use-transcript-jumps.ts`; every jump on this surface comes
|
|
783
|
-
// through the one function it returns.
|
|
784
|
-
const jumpToRow = useTranscriptJumps({
|
|
785
|
-
rows,
|
|
786
|
-
terminal,
|
|
787
|
-
stickyPrompt,
|
|
788
|
-
epoch,
|
|
789
|
-
promptRows,
|
|
790
|
-
scrollElement,
|
|
791
|
-
rowsRef,
|
|
792
|
-
virtualizer,
|
|
793
|
-
stick,
|
|
794
|
-
jumpToRecapRef,
|
|
795
|
-
repinRef,
|
|
796
|
-
})
|
|
797
|
-
|
|
798
|
-
// Reveal a tool call from outside the transcript — a sub-agent picked in a
|
|
799
|
-
// sessions list, whose `Task` row is the thing the reader asked for.
|
|
800
|
-
//
|
|
801
|
-
// Keyed on the **nonce**, never on the id: asking for the same sub-agent twice
|
|
802
|
-
// is a second request, and a props-equal effect would answer only the first.
|
|
803
|
-
// The lookup goes through `rowIndexForItem` for the reason that function
|
|
804
|
-
// exists — a row covers a *membership*, not a contiguous span, so a Task's id
|
|
805
|
-
// resolves to the folded row that absorbed it rather than to a position. That
|
|
806
|
-
// also makes a nested child's id work, which is what a client holding only a
|
|
807
|
-
// `parentToolUseId` can offer.
|
|
808
|
-
//
|
|
809
|
-
// `'start'`, like the recap seam and the scrubber's marks: the sub-agent's
|
|
810
|
-
// work runs *downward* from its row, so the reader wants the screen below it.
|
|
811
|
-
const revealNonce = reveal?.nonce
|
|
812
|
-
const revealId = reveal?.toolUseId
|
|
813
|
-
useEffect(() => {
|
|
814
|
-
if (revealId === undefined) return
|
|
815
|
-
const itemIndex = items.findIndex(
|
|
816
|
-
(item) => item.kind === 'tool_call' && item.id === revealId,
|
|
817
|
-
)
|
|
818
|
-
// Not here: a compaction, a `/clear`, or simply a client whose list knows
|
|
819
|
-
// about a Task this transcript has not replayed yet. Staying put beats
|
|
820
|
-
// jumping somewhere arbitrary.
|
|
821
|
-
if (itemIndex < 0) return
|
|
822
|
-
jumpToRow(rowIndexForItem(rows, itemIndex), 'start')
|
|
823
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- the nonce IS the trigger
|
|
824
|
-
}, [revealNonce])
|
|
825
|
-
|
|
826
|
-
// The scrubber. Interactivity follows the hover affordance — with
|
|
827
|
-
// `affordances={false}` the rail is passive paint (pointer-events off) and
|
|
828
|
-
// the native scrollbar stays; interactive, the rail IS the scrollbar, so the
|
|
829
|
-
// native one is hidden via an attribute on the scroll element.
|
|
830
|
-
const scrubInteractive = resolveAffordances(affordances).hover
|
|
831
|
-
const recapIndex = rows.findIndex((row) => row.key === 'recap')
|
|
832
|
-
const recapRow =
|
|
833
|
-
recapIndex >= 0
|
|
834
|
-
? { rowIndex: recapIndex, label: (rows[recapIndex] as { line: string }).line }
|
|
835
|
-
: undefined
|
|
836
|
-
useEffect(() => {
|
|
837
|
-
if (!terminal || !scrubber || !scrubInteractive || !scrollElement) return
|
|
838
|
-
scrollElement.setAttribute('data-term-scrubber-host', '')
|
|
839
|
-
return () => scrollElement.removeAttribute('data-term-scrubber-host')
|
|
840
|
-
}, [terminal, scrubber, scrubInteractive, scrollElement])
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* The pinned prompt: the prompt's **first line**, held at the top of the
|
|
844
|
-
* scroller.
|
|
845
|
-
*
|
|
846
|
-
* One line, not the row: a pasted twenty-line prompt pinned whole covers the
|
|
847
|
-
* viewport and buries the very answer being read under it. So what pins is a
|
|
848
|
-
* **head** — one line tall, `overflow: hidden` — whose content is the same
|
|
849
|
-
* row rendered again, laid exactly over the real row's first line. It is a
|
|
850
|
-
* duplicate, which an earlier design here rejected — but that rejection was
|
|
851
|
-
* of a *separate header* with its own padding and its own idea of the
|
|
852
|
-
* gutter. This copy is the same component in the same column at the same
|
|
853
|
-
* width, so it aligns with the row beneath by construction, and while the
|
|
854
|
-
* row is in flow the overlay is pixel-identical and invisible. It takes no
|
|
855
|
-
* pointer events and is `aria-hidden`: the real row owns interaction and
|
|
856
|
-
* the accessibility tree; the head is paint.
|
|
857
|
-
*
|
|
858
|
-
* The pin itself is the browser's, not ours. Each prompt row renders inside a
|
|
859
|
-
* **lane**: an absolutely positioned strip spanning from the prompt's start to
|
|
860
|
-
* the next prompt's (its turn), with the head `position: sticky` inside it
|
|
861
|
-
* (its flow footprint cancelled by a negative bottom margin, so the real row
|
|
862
|
-
* sits at the lane's top as if the head were not there). The compositor does
|
|
863
|
-
* the pinning and the lane's bottom edge does the push-off — `sticky` is
|
|
864
|
-
* inert on an absolutely positioned element, but works unchanged on a child
|
|
865
|
-
* *of* one, confined to the lane's box. An earlier version clamped the row's
|
|
866
|
-
* transform from render instead, and paid for it every frame: any JS-written
|
|
867
|
-
* pin — React render or a raw scroll handler — runs behind the compositor
|
|
868
|
-
* thread, so the row wobbled under momentum scroll. With the lane there is
|
|
869
|
-
* no per-scroll JS and no lag.
|
|
870
|
-
*
|
|
871
|
-
* Which row — the last prompt at or above the fold, the question the answer
|
|
872
|
-
* on screen belongs to — falls out of the geometry: only the lane spanning
|
|
873
|
-
* the viewport top has its child stuck; every earlier lane has pushed its row
|
|
874
|
-
* off its bottom edge, every later one hasn't reached the top.
|
|
875
|
-
*
|
|
876
|
-
* The one job left to JS is keeping that row *mounted* once it scrolls far
|
|
877
|
-
* above the virtual window — which is exactly when it is working. That lives
|
|
878
|
-
* in the `rangeExtractor` above.
|
|
879
|
-
*/
|
|
880
|
-
const measurements = virtualizer.measurementsCache
|
|
881
|
-
|
|
882
|
-
return (
|
|
883
|
-
<div
|
|
884
|
-
ref={rowsRef}
|
|
885
|
-
data-slot='transcript-rows'
|
|
886
|
-
className='relative w-full'
|
|
887
|
-
style={{ height: virtualizer.getTotalSize() }}>
|
|
888
|
-
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
889
|
-
const row = rows[virtualRow.index]
|
|
890
|
-
// The inter-row gap, folded into each row so the measured height
|
|
891
|
-
// carries it: flex `gap` cannot reach absolutely positioned rows,
|
|
892
|
-
// and a pixel constant for the virtualizer's `gap` option would
|
|
893
|
-
// drift from the rem the layout is set in. On the measured wrapper,
|
|
894
|
-
// not the row div, so a nested row's left border still breaks
|
|
895
|
-
// across the gap as it did under flex. Skipped for the first row —
|
|
896
|
-
// a gap above it would be padding, not spacing.
|
|
897
|
-
// Every variant but terminal spaces every row alike. Terminal
|
|
898
|
-
// asks whether the pair belongs together — a tool call and its
|
|
899
|
-
// output get no blank line, exactly as the CLI leaves none.
|
|
900
|
-
const gapClass =
|
|
901
|
-
virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index))
|
|
902
|
-
? gap.className
|
|
903
|
-
: undefined
|
|
904
|
-
const content =
|
|
905
|
-
'run' in row ? (
|
|
906
|
-
<div className={cn(read(boundary, row.index) && 'opacity-45')}>
|
|
907
|
-
<ToolRunRow items={row.run} />
|
|
908
|
-
</div>
|
|
909
|
-
) : 'item' in row ? (
|
|
910
|
-
<div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
|
|
911
|
-
<TranscriptItemView
|
|
912
|
-
item={row.item}
|
|
913
|
-
fileUrl={fileUrl}
|
|
914
|
-
attachmentUrl={attachmentUrl}
|
|
915
|
-
hostImage={hostImage}
|
|
916
|
-
terminal={terminal}
|
|
917
|
-
/>
|
|
918
|
-
</div>
|
|
919
|
-
) : 'text' in row && row.key === 'brief' ? (
|
|
920
|
-
<BriefRow text={row.text} terminal={terminal} />
|
|
921
|
-
) : 'line' in row ? (
|
|
922
|
-
<RecapRow line={row.line} since={since} terminal={terminal} />
|
|
923
|
-
) : (
|
|
924
|
-
// A task block. No `nestedClass` here: the rule belongs *inside*
|
|
925
|
-
// the row, around the children it opens onto — the collapsed line
|
|
926
|
-
// is the main thread's, and stepping it in would say a `Task` call
|
|
927
|
-
// happened somewhere else.
|
|
928
|
-
<div className={cn(read(boundary, row.index) && 'opacity-45')}>
|
|
929
|
-
<TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
|
|
930
|
-
</div>
|
|
931
|
-
)
|
|
932
|
-
// A prompt row's sticky lane — see the pinned-prompt comment above.
|
|
933
|
-
// The lane is sized to the turn; the sticky **head** comes first, out
|
|
934
|
-
// of flow, and the *measured* element is the real row after it, so
|
|
935
|
-
// the virtualizer's heights are untouched by either. Under terminal
|
|
936
|
-
// the head is the same content again clipped to one line, and carries
|
|
937
|
-
// the row's gap class so its visible line sits on the same y while in
|
|
938
|
-
// flow (the pin parks that padding above the viewport edge when
|
|
939
|
-
// stuck). Under cards the head is the prompt as plain text — theme.css
|
|
940
|
-
// draws it as a compact bar — and carries no gap class: it is never
|
|
941
|
-
// an in-flow overlay of the row, so it has no y to match.
|
|
942
|
-
// Positioned with `top`, NOT the translate every other row gets:
|
|
943
|
-
// `position: sticky` is resolved at layout time and a transform is
|
|
944
|
-
// paint-only, so under a translate the head would stick against the
|
|
945
|
-
// lane's un-translated box at the top of the list — observed as the
|
|
946
|
-
// row clamped to its lane's bottom edge, never pinning at all.
|
|
947
|
-
// Same predicate as `promptRows` above — the lane and the forced range
|
|
948
|
-
// must agree on which rows are prompts.
|
|
949
|
-
if (
|
|
950
|
-
stickyPrompt &&
|
|
951
|
-
'item' in row &&
|
|
952
|
-
row.item.kind === 'user' &&
|
|
953
|
-
parentOf(row.item) === undefined
|
|
954
|
-
) {
|
|
955
|
-
const next = promptRows.find((index) => index > virtualRow.index)
|
|
956
|
-
const laneEnd =
|
|
957
|
-
next === undefined
|
|
958
|
-
? virtualizer.getTotalSize()
|
|
959
|
-
: (measurements[next]?.start ?? virtualRow.start)
|
|
960
|
-
return (
|
|
961
|
-
<StickyPromptLane
|
|
962
|
-
key={row.key}
|
|
963
|
-
top={virtualRow.start}
|
|
964
|
-
height={Math.max(laneEnd - virtualRow.start, 0)}
|
|
965
|
-
gapClass={gapClass}
|
|
966
|
-
gapPx={gap.px}
|
|
967
|
-
terminal={terminal}
|
|
968
|
-
scrollRoot={scrollElement}
|
|
969
|
-
index={virtualRow.index}
|
|
970
|
-
measureRef={virtualizer.measureElement}
|
|
971
|
-
head={terminal ? content : promptHeadText(row.item)}
|
|
972
|
-
content={content}
|
|
973
|
-
/>
|
|
974
|
-
)
|
|
975
|
-
}
|
|
976
|
-
return (
|
|
977
|
-
<div
|
|
978
|
-
key={row.key}
|
|
979
|
-
ref={virtualizer.measureElement}
|
|
980
|
-
data-index={virtualRow.index}
|
|
981
|
-
className={cn('absolute inset-x-0 top-0', gapClass)}
|
|
982
|
-
style={{ transform: `translateY(${virtualRow.start}px)` }}>
|
|
983
|
-
{content}
|
|
984
|
-
</div>
|
|
985
|
-
)
|
|
986
|
-
})}
|
|
987
|
-
{/* The overview ruler, portalled beside the scroll element rather than
|
|
988
|
-
rendered as content: it must not scroll with the rows it maps. It
|
|
989
|
-
lives here, not in the shell above, because everything it draws from
|
|
990
|
-
— the virtualizer's offsets, the epoch, the row list, the jump — is
|
|
991
|
-
this component's. The portal target is the Conversation root
|
|
992
|
-
(`relative`), the same containing block the scroll button uses. */}
|
|
993
|
-
{scrubber && scrollElement?.parentElement
|
|
994
|
-
? createPortal(
|
|
995
|
-
terminal ? (
|
|
996
|
-
<TerminalScrubber
|
|
997
|
-
items={items}
|
|
998
|
-
pendingApprovals={pendingApprovals}
|
|
999
|
-
recapRow={recapRow}
|
|
1000
|
-
bookmarks={scrubberMarks ?? []}
|
|
1001
|
-
frameParentId={frameParentId}
|
|
1002
|
-
rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
|
|
1003
|
-
positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
|
|
1004
|
-
offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
|
|
1005
|
-
sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
|
|
1006
|
-
totalSize={virtualizer.getTotalSize()}
|
|
1007
|
-
scrollOffset={virtualizer.scrollOffset ?? 0}
|
|
1008
|
-
viewportH={virtualizer.scrollRect?.height ?? 0}
|
|
1009
|
-
onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
|
|
1010
|
-
interactive={scrubInteractive}
|
|
1011
|
-
fontSize={fontSize}
|
|
1012
|
-
lineHeight={lineHeight}
|
|
1013
|
-
/>
|
|
1014
|
-
) : (
|
|
1015
|
-
<Scrubber
|
|
1016
|
-
items={items}
|
|
1017
|
-
pendingApprovals={pendingApprovals}
|
|
1018
|
-
recapItemIndex={recapIndex >= 0 ? boundary : undefined}
|
|
1019
|
-
bookmarks={scrubberMarks}
|
|
1020
|
-
frameParentId={frameParentId}
|
|
1021
|
-
interactive={scrubInteractive}
|
|
1022
|
-
onJumpToItem={(itemIndex) => jumpToRow(rowIndexForItem(rows, itemIndex), 'start')}
|
|
1023
|
-
/>
|
|
1024
|
-
),
|
|
1025
|
-
scrollElement.parentElement,
|
|
1026
|
-
)
|
|
1027
|
-
: null}
|
|
1028
|
-
</div>
|
|
1029
|
-
)
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
67
|
export interface TranscriptProps {
|
|
1033
68
|
state: TranscriptState
|
|
1034
|
-
/** Builds the download URL for a delivered file (see FileCard). Typically
|
|
1035
|
-
* `(path) => client.sessionFileUrl(sessionId, path)`. */
|
|
1036
69
|
fileUrl?: (path: string) => string
|
|
1037
|
-
/** Builds the URL for an uploaded attachment. Typically
|
|
1038
|
-
* `(id) => client.attachmentUrl(sessionId, id)`. Same-origin and
|
|
1039
|
-
* cookie-authenticated, which is what lets an `<img src>` render one. */
|
|
1040
70
|
attachmentUrl?: (attachmentId: string) => string
|
|
1041
|
-
/** Whether this gateway serves `@file` search here — the empty state must not
|
|
1042
|
-
* advertise an affordance the composer doesn't have. */
|
|
1043
71
|
canBrowseFiles?: boolean
|
|
1044
|
-
/** Reads a host file as a data URL, for tool calls whose output is a picture
|
|
1045
|
-
* on the host (codex's `image_gen`). Omit and those cards name the path. */
|
|
1046
72
|
hostImage?: (path: string) => Promise<string | undefined>
|
|
1047
|
-
/**
|
|
1048
|
-
* How a turn is drawn: `cards` (default, the chat convention) or `terminal`,
|
|
1049
|
-
* which is not a set of branches in these components but its own renderer.
|
|
1050
|
-
* See {@link TranscriptVariant}.
|
|
1051
|
-
*/
|
|
1052
73
|
variant?: TranscriptVariant
|
|
1053
|
-
/**
|
|
1054
|
-
* How much air each row gets: `comfortable` (default — a blank line between
|
|
1055
|
-
* messages, as the Claude Code CLI does) or `compact`. Independent of
|
|
1056
|
-
* {@link TranscriptVariant}. See {@link TranscriptDensity}.
|
|
1057
|
-
*/
|
|
1058
74
|
density?: TranscriptDensity
|
|
1059
|
-
/** Terminal theme only: the character cell, in whole pixels. See
|
|
1060
|
-
* {@link TerminalSurface}. */
|
|
1061
75
|
fontSize?: number
|
|
1062
76
|
lineHeight?: number
|
|
1063
|
-
/** Terminal theme only: the pointer affordances a real terminal cannot offer.
|
|
1064
|
-
* `false` for none. See {@link TerminalAffordances}. */
|
|
1065
77
|
affordances?: TerminalAffordances | boolean
|
|
1066
|
-
/** Hold the prompt of the turn being read at the top of the scroller. Works
|
|
1067
|
-
* in both variants: the terminal clips to one line, cards shows a frosted
|
|
1068
|
-
* bar. The *real* row is pinned, not a copy — see `TranscriptRows`. */
|
|
1069
78
|
stickyPrompt?: boolean
|
|
1070
|
-
/**
|
|
1071
|
-
* Mount the overview scrubber — a 12px rail of marks (your prompts, each
|
|
1072
|
-
* turn's response and result as one mark, errors, the pending approval, the
|
|
1073
|
-
* catch-up boundary) over the scroller's right edge. Two rails behind one
|
|
1074
|
-
* prop: under `terminal` it is the pixel-exact ruler that replaces the
|
|
1075
|
-
* native scrollbar (positions ride the height calculator; drag scrubs), and
|
|
1076
|
-
* under `cards` it is the **proportional annotation rail** — positioned by
|
|
1077
|
-
* `itemIndex / items.length`, because proportional text gives the
|
|
1078
|
-
* calculator no claim there — where the native scrollbar stays and the rail
|
|
1079
|
-
* only peeks and jumps. With `affordances={false}` either rail degrades to
|
|
1080
|
-
* passive paint — no drag, peek or click.
|
|
1081
|
-
*/
|
|
1082
79
|
scrubber?: boolean
|
|
1083
|
-
|
|
1084
|
-
* Bookmarked item indices, painted as full-width marks on the rail. Paint
|
|
1085
|
-
* only, deliberately: the store — and the affordance that writes it — is
|
|
1086
|
-
* the client's, the way the unread watermarks are, not the panel's.
|
|
1087
|
-
*/
|
|
1088
|
-
scrubberMarks?: readonly number[]
|
|
1089
|
-
/**
|
|
1090
|
-
* The attach replay is still landing (`useClaudeSession().replaying`): rows
|
|
1091
|
-
* render, measure and pin exactly as normal but nothing paints, and a loading
|
|
1092
|
-
* line shows in their place; when it flips false the settled tail appears in
|
|
1093
|
-
* one frame. Hiding is by *visibility*, never by not mounting — see the
|
|
1094
|
-
* comment at the render site. Optional: an embedder that never passes it
|
|
1095
|
-
* gets today's behaviour, and a short or empty session holds for no visible
|
|
1096
|
-
* time at all (the frame between attach and replay-complete).
|
|
1097
|
-
*/
|
|
80
|
+
bookmarks?: readonly string[]
|
|
1098
81
|
replaying?: boolean
|
|
1099
|
-
/**
|
|
1100
|
-
* Catch-up: `from` is how many items had been seen last time, `since` when
|
|
1101
|
-
* that was. A recap row is drawn at that boundary and everything above it is
|
|
1102
|
-
* dimmed. Omit (or pass a boundary at/after the end) and the transcript
|
|
1103
|
-
* renders exactly as before.
|
|
1104
|
-
*/
|
|
1105
82
|
catchUp?: { from: number; since?: number }
|
|
1106
|
-
/**
|
|
1107
|
-
* Filled with a closure that scrolls the recap row into view — the seam the
|
|
1108
|
-
* panel's catch-up strip presses. A ref rather than a DOM query because the
|
|
1109
|
-
* rows are virtualized: when the recap row isn't mounted, only the
|
|
1110
|
-
* virtualizer knows where it would be. Optional; embedders without a
|
|
1111
|
-
* catch-up strip never touch it. `null` while no transcript is mounted.
|
|
1112
|
-
*/
|
|
1113
83
|
jumpToRecapRef?: RefObject<(() => void) | null>
|
|
1114
|
-
/**
|
|
1115
|
-
* Filled with a closure that re-pins the transcript to the bottom, so a host
|
|
1116
|
-
* can resume following after the reader has scrolled away. The panel presses
|
|
1117
|
-
* it on send: choosing to say something is choosing to watch what happens
|
|
1118
|
-
* next, and a transcript left parked where you were reading makes a sent
|
|
1119
|
-
* message look like it did nothing at all.
|
|
1120
|
-
*/
|
|
1121
84
|
repinRef?: RefObject<(() => void) | null>
|
|
1122
|
-
/**
|
|
1123
|
-
* Scroll a tool call into view — bump `nonce` to ask again for the same one.
|
|
1124
|
-
*
|
|
1125
|
-
* The seam a *list* needs: sub-agent work is nested inside the `Task` call
|
|
1126
|
-
* that spawned it, so "open that sub-agent" can only ever mean "take me to its
|
|
1127
|
-
* row". A `parentToolUseId` works here as well as the Task's own id, since the
|
|
1128
|
-
* lookup resolves an absorbed child to the row that folded it.
|
|
1129
|
-
*
|
|
1130
|
-
* A prop rather than a ref (the shape `jumpToRecapRef` uses) because the asker
|
|
1131
|
-
* is outside this webview entirely and the request travels as data; a ref
|
|
1132
|
-
* would need a live closure at the other end of a postMessage bridge.
|
|
1133
|
-
*/
|
|
1134
85
|
reveal?: { toolUseId: string; nonce: number }
|
|
1135
|
-
/**
|
|
1136
|
-
* Render **only** the work one sub-agent did, rather than the conversation —
|
|
1137
|
-
* the sub-agent takeover's frame.
|
|
1138
|
-
*
|
|
1139
|
-
* Membership is `subagentItems` (`terminal/blocks.ts`), which is also the rule
|
|
1140
|
-
* iOS will mirror: everything the agent produced, and not the spawning `Task`
|
|
1141
|
-
* call itself, which *is* the frame rather than a row in it.
|
|
1142
|
-
*
|
|
1143
|
-
* Features are switched off internally whenever it is set, and the gate lives
|
|
1144
|
-
* here rather than at the call site on purpose: each is keyed to a
|
|
1145
|
-
* **full-transcript item index**, so a host that passed a frame and a catch-up
|
|
1146
|
-
* boundary together would not be making a strange choice, it would be making
|
|
1147
|
-
* an incoherent one. Those are the catch-up boundary and its recap row, the
|
|
1148
|
-
* sticky prompt, `reveal`, and the scrubber's **bookmarks** (host indices in
|
|
1149
|
-
* full-transcript space).
|
|
1150
|
-
*
|
|
1151
|
-
* The **scrubber itself stays**, and the distinction is the point: the rail
|
|
1152
|
-
* derives every one of its inputs from the rows it is given, and inside a
|
|
1153
|
-
* frame those are the sub-agent's own — so it marks that agent's prompts,
|
|
1154
|
-
* answers and failures at that agent's offsets. It was originally gated with
|
|
1155
|
-
* the marks, on the reasonable-looking argument that they are one feature;
|
|
1156
|
-
* they are two, and a fifty-tool agent run is exactly where a rail earns its
|
|
1157
|
-
* keep. What stays besides is everything that makes a long stream readable —
|
|
1158
|
-
* virtualization, the height epoch, the follow spring, the replay hold.
|
|
1159
|
-
*/
|
|
1160
86
|
frame?: { parentToolUseId: string }
|
|
1161
|
-
/**
|
|
1162
|
-
* Raise the takeover from a `Task` row. Absent draws no affordance, which is
|
|
1163
|
-
* what the plain renderer and the cards variant get: cards folds nothing, so
|
|
1164
|
-
* it has no task blocks to hang this on, and reaches the takeover from the
|
|
1165
|
-
* sessions list instead.
|
|
1166
|
-
*/
|
|
1167
87
|
onOpenSubagent?: (toolUseId: string) => void
|
|
1168
|
-
/**
|
|
1169
|
-
* Replaces the default empty state when the transcript has no items. Pass a
|
|
1170
|
-
* `ReactNode` to show your product's own onboarding instead of WorkerDeck's
|
|
1171
|
-
* generic "`>_` Tell the agent what to do." placeholder.
|
|
1172
|
-
*/
|
|
1173
88
|
emptyState?: ReactNode
|
|
1174
89
|
className?: string
|
|
1175
90
|
}
|
|
@@ -1187,7 +102,7 @@ export function Transcript({
|
|
|
1187
102
|
affordances,
|
|
1188
103
|
stickyPrompt = false,
|
|
1189
104
|
scrubber,
|
|
1190
|
-
|
|
105
|
+
bookmarks,
|
|
1191
106
|
replaying = false,
|
|
1192
107
|
catchUp,
|
|
1193
108
|
jumpToRecapRef,
|
|
@@ -1199,211 +114,111 @@ export function Transcript({
|
|
|
1199
114
|
className,
|
|
1200
115
|
}: TranscriptProps) {
|
|
1201
116
|
const terminal = variant === 'terminal'
|
|
1202
|
-
|
|
1203
|
-
// decided. Everything below reads `items` rather than `state.items`, so the
|
|
1204
|
-
// row build, the empty state and the loader all describe the same surface.
|
|
1205
|
-
const items = useMemo(
|
|
1206
|
-
() => (frame ? subagentItems(state.items, frame.parentToolUseId) : state.items),
|
|
1207
|
-
[state.items, frame],
|
|
1208
|
-
)
|
|
1209
|
-
// The spawning call, for the header's claim and to tell "not here yet" from
|
|
1210
|
-
// "not in this transcript" — see the frame placeholder below.
|
|
117
|
+
const items = useMemo(() => (frame ? subagentItems(state.items, frame.parentToolUseId) : state.items), [state.items, frame])
|
|
1211
118
|
const frameTask = useMemo(
|
|
1212
119
|
() =>
|
|
1213
|
-
frame
|
|
1214
|
-
? state.items.find(
|
|
1215
|
-
(item): item is ToolCallItem =>
|
|
1216
|
-
item.kind === 'tool_call' && item.id === frame.parentToolUseId,
|
|
1217
|
-
)
|
|
1218
|
-
: undefined,
|
|
120
|
+
frame ? state.items.find((item): item is ToolCallItem => item.kind === 'tool_call' && item.id === frame.parentToolUseId) : undefined,
|
|
1219
121
|
[state.items, frame],
|
|
1220
122
|
)
|
|
1221
123
|
const gap = ROW_GAP[variant][density]
|
|
1222
124
|
const runStartedAt = useRunStart(state.status)
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
// That "past the end" arm now also covers a `/clear`. The mark a client
|
|
1226
|
-
// stored is an item index, and `conversation_reset` empties `items` while
|
|
1227
|
-
// `activityCount` stays monotonic (it is an unread cursor, not an item count
|
|
1228
|
-
// — a count that went backwards would silence the badge for good against a
|
|
1229
|
-
// monotonic watermark store). So a session returned to after a clear has a
|
|
1230
|
-
// boundary well past its few fresh rows and gets **no recap row**, which is
|
|
1231
|
-
// the honest answer: an index into a conversation that no longer exists
|
|
1232
|
-
// cannot say what you missed. Clamping it would land on `items.length` and
|
|
1233
|
-
// read as "nothing is new" — the same outcome, told less truthfully.
|
|
1234
|
-
const boundary =
|
|
1235
|
-
!frame && catchUp && catchUp.from > 0 && catchUp.from < state.items.length
|
|
1236
|
-
? catchUp.from
|
|
1237
|
-
: undefined
|
|
1238
|
-
const recap = useMemo(
|
|
1239
|
-
() => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))),
|
|
1240
|
-
[state, boundary],
|
|
1241
|
-
)
|
|
1242
|
-
// What this agent was asked, when the stream does not already say. A
|
|
1243
|
-
// foreground `Task` forwards its brief as a real nested user message and it is
|
|
1244
|
-
// already the frame's first row; a background agent forwards nothing, and
|
|
1245
|
-
// without this the takeover shows an answer with the question missing. Hence
|
|
1246
|
-
// the guard rather than an unconditional splice — drawn both ways, the reader
|
|
1247
|
-
// would see the same instruction twice.
|
|
125
|
+
const boundary = !frame && catchUp && catchUp.from > 0 && catchUp.from < state.items.length ? catchUp.from : undefined
|
|
126
|
+
const recap = useMemo(() => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))), [state, boundary])
|
|
1248
127
|
const brief = useMemo(
|
|
1249
|
-
() =>
|
|
1250
|
-
frame && frameTask && !items.some((item) => item.kind === 'user')
|
|
1251
|
-
? taskBrief(frameTask)
|
|
1252
|
-
: undefined,
|
|
128
|
+
() => (frame && frameTask && !items.some((item) => item.kind === 'user') ? taskBrief(frameTask) : undefined),
|
|
1253
129
|
[frame, frameTask, items],
|
|
1254
130
|
)
|
|
1255
131
|
const rows = useMemo<TranscriptRow[]>(() => {
|
|
1256
|
-
const fold = (from: number, to: number) =>
|
|
1257
|
-
terminalBlocks(items.slice(from, to), from, terminal)
|
|
132
|
+
const fold = (from: number, to: number) => terminalBlocks(items.slice(from, to), from, terminal)
|
|
1258
133
|
const lead: TranscriptRow[] = brief ? [{ key: 'brief' as const, text: brief }] : []
|
|
1259
|
-
if (boundary === undefined || !recap)
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
return [
|
|
1264
|
-
...fold(0, boundary),
|
|
1265
|
-
{ key: 'recap' as const, line: recap },
|
|
1266
|
-
...fold(boundary, items.length),
|
|
1267
|
-
]
|
|
134
|
+
if (boundary === undefined || !recap) {
|
|
135
|
+
return [...lead, ...fold(0, items.length)]
|
|
136
|
+
}
|
|
137
|
+
return [...fold(0, boundary), { key: 'recap' as const, line: recap }, ...fold(boundary, items.length)]
|
|
1268
138
|
}, [items, boundary, recap, terminal, brief])
|
|
1269
139
|
return (
|
|
1270
140
|
<TranscriptVariantProvider value={variant}>
|
|
1271
|
-
{/* The replay hold hides by VISIBILITY, never by not mounting. The rows
|
|
1272
|
-
must exist and lay out while hidden: the virtualizer measures them,
|
|
1273
|
-
the height epoch builds, and the follow pin settles on the real
|
|
1274
|
-
bottom — so the reveal is the removal of one style, a single paint of
|
|
1275
|
-
an already-settled tail, and the catch-up jump always fires against a
|
|
1276
|
-
measured list. (Unmounting instead would replay the entire
|
|
1277
|
-
mount-measure-correct churn, visibly, at reveal time.) `visibility`
|
|
1278
|
-
is the one hiding property a descendant can turn back ON, which is
|
|
1279
|
-
how the loading line below stays visible inside a hidden root — and
|
|
1280
|
-
the root is the right scope because the scrubber and the scroll
|
|
1281
|
-
button portal/position into it, not into the scroller. */}
|
|
1282
141
|
<Conversation className={cn(replaying && 'invisible', className)}>
|
|
1283
142
|
<ConversationContent className={cn(terminal && 'gap-0 p-0')}>
|
|
1284
|
-
<TerminalShell
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
) : null
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
143
|
+
<TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
|
|
144
|
+
{frame ? (
|
|
145
|
+
frameTask === undefined && !replaying ? (
|
|
146
|
+
<div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
|
|
147
|
+
This sub-agent's work is not in this transcript.
|
|
148
|
+
</div>
|
|
149
|
+
) : null
|
|
150
|
+
) : items.length === 0 && state.status !== 'starting' ? (
|
|
151
|
+
emptyState !== undefined ? (
|
|
152
|
+
emptyState
|
|
153
|
+
) : (
|
|
154
|
+
<SessionEmptyState
|
|
155
|
+
cwd={state.cwd}
|
|
156
|
+
hasCommands={!!state.commands?.length}
|
|
157
|
+
hasSkills={!!state.skills?.some((s) => s.enabled)}
|
|
158
|
+
canBrowseFiles={canBrowseFiles}
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
) : null}
|
|
162
|
+
{frame && frameTask === undefined && !replaying ? null : (
|
|
163
|
+
<TranscriptRows
|
|
164
|
+
rows={rows}
|
|
165
|
+
boundary={boundary}
|
|
166
|
+
since={catchUp?.since}
|
|
167
|
+
terminal={terminal}
|
|
168
|
+
replaying={replaying}
|
|
169
|
+
stickyPrompt={!frame && stickyPrompt}
|
|
170
|
+
gap={gap}
|
|
171
|
+
fontSize={fontSize}
|
|
172
|
+
lineHeight={lineHeight}
|
|
173
|
+
items={items}
|
|
174
|
+
pendingApprovals={state.pendingApprovals}
|
|
175
|
+
scrubber={scrubber}
|
|
176
|
+
bookmarks={frame ? undefined : bookmarks}
|
|
177
|
+
affordances={affordances}
|
|
178
|
+
fileUrl={fileUrl}
|
|
179
|
+
attachmentUrl={attachmentUrl}
|
|
180
|
+
hostImage={hostImage}
|
|
181
|
+
jumpToRecapRef={jumpToRecapRef}
|
|
182
|
+
repinRef={repinRef}
|
|
183
|
+
reveal={frame ? undefined : reveal}
|
|
184
|
+
frameParentId={frame?.parentToolUseId}
|
|
185
|
+
onOpenSubagent={frame ? undefined : onOpenSubagent}
|
|
1312
186
|
/>
|
|
1313
|
-
)
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
pendingApprovals={state.pendingApprovals}
|
|
1328
|
-
/* The rail rides the frame's OWN rows, so inside a takeover it
|
|
1329
|
-
marks the sub-agent's prompts, answers and failures — the thing
|
|
1330
|
-
a long agent run most needs, and coherent because every input
|
|
1331
|
-
it takes (`items`, `rowIndexFor`, `offsetOfRow`) is the frame's.
|
|
1332
|
-
The **bookmarks are not**: those indices are the host's, in
|
|
1333
|
-
full-transcript space, and painting them here would put marks
|
|
1334
|
-
at meaningless offsets. See `frame`'s doc for the rest. */
|
|
1335
|
-
scrubber={scrubber}
|
|
1336
|
-
scrubberMarks={frame ? undefined : scrubberMarks}
|
|
1337
|
-
affordances={affordances}
|
|
1338
|
-
fileUrl={fileUrl}
|
|
1339
|
-
attachmentUrl={attachmentUrl}
|
|
1340
|
-
hostImage={hostImage}
|
|
1341
|
-
jumpToRecapRef={jumpToRecapRef}
|
|
1342
|
-
repinRef={repinRef}
|
|
1343
|
-
reveal={frame ? undefined : reveal}
|
|
1344
|
-
frameParentId={frame?.parentToolUseId}
|
|
1345
|
-
onOpenSubagent={frame ? undefined : onOpenSubagent}
|
|
1346
|
-
/>
|
|
1347
|
-
)}
|
|
1348
|
-
{(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
|
|
1349
|
-
terminal ? (
|
|
1350
|
-
// The CLI's own working line, and it is a *row of the transcript*
|
|
1351
|
-
// rather than a spinner floating over it — one blank line down,
|
|
1352
|
-
// like every other block.
|
|
1353
|
-
<>
|
|
1354
|
-
{state.items.length > 0 ? <div className='term-blank' aria-hidden /> : null}
|
|
1355
|
-
<WorkingRow
|
|
1356
|
-
label={state.status === 'starting' ? 'Starting…' : 'Working…'}
|
|
187
|
+
)}
|
|
188
|
+
{(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
|
|
189
|
+
terminal ? (
|
|
190
|
+
<>
|
|
191
|
+
{state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
|
|
192
|
+
<WorkingRow
|
|
193
|
+
label={state.status === 'starting' ? 'Starting…' : 'Working…'}
|
|
194
|
+
startedAt={runStartedAt}
|
|
195
|
+
tokens={state.contextUsage?.totalTokens}
|
|
196
|
+
/>
|
|
197
|
+
</>
|
|
198
|
+
) : (
|
|
199
|
+
<Loader
|
|
200
|
+
label={state.status === 'starting' ? 'Starting session…' : undefined}
|
|
1357
201
|
startedAt={runStartedAt}
|
|
1358
202
|
tokens={state.contextUsage?.totalTokens}
|
|
1359
203
|
/>
|
|
1360
|
-
|
|
1361
|
-
) :
|
|
1362
|
-
<Loader
|
|
1363
|
-
label={state.status === 'starting' ? 'Starting session…' : undefined}
|
|
1364
|
-
startedAt={runStartedAt}
|
|
1365
|
-
tokens={state.contextUsage?.totalTokens}
|
|
1366
|
-
/>
|
|
1367
|
-
)
|
|
1368
|
-
) : null}
|
|
204
|
+
)
|
|
205
|
+
) : null}
|
|
1369
206
|
</TerminalShell>
|
|
1370
207
|
</ConversationContent>
|
|
1371
208
|
<ConversationScrollButton />
|
|
1372
|
-
{/* What shows while the hold is on — and for a normal attach that is
|
|
1373
|
-
*nothing*. `wd-hold-appear` (theme.css) keeps it at `opacity: 0`
|
|
1374
|
-
and fades it in only after 600ms, so a healthy ~0.5s hold unmounts
|
|
1375
|
-
it before it ever paints. An unconditional line here was worse than
|
|
1376
|
-
no hold at all: it appeared and vanished inside half a second, which
|
|
1377
|
-
is the flicker the hold exists to remove, relocated to the top of
|
|
1378
|
-
the panel. Only a genuinely slow attach earns a placeholder, which
|
|
1379
|
-
is the case where a reader would otherwise think the panel is dead.
|
|
1380
|
-
An overlay rather than a flow row
|
|
1381
|
-
because the hidden content is at full height and pinned to its
|
|
1382
|
-
bottom; a row in flow would sit at the bottom edge. It mirrors
|
|
1383
|
-
`ConversationContent`'s wrapper (not the component itself — a
|
|
1384
|
-
second `StickToBottom.Content` would steal the library's content
|
|
1385
|
-
ref) so the paddings line up with the real rows'. */}
|
|
1386
209
|
{replaying ? (
|
|
1387
210
|
<div
|
|
1388
|
-
data-slot=
|
|
211
|
+
data-slot="transcript-hold"
|
|
1389
212
|
aria-hidden
|
|
1390
|
-
className=
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
'mx-auto w-full max-w-[var(--wd-transcript-max-width)]',
|
|
1394
|
-
!terminal && 'px-4 py-4',
|
|
1395
|
-
)}>
|
|
213
|
+
className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
|
|
214
|
+
>
|
|
215
|
+
<div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
|
|
1396
216
|
{terminal ? (
|
|
1397
|
-
<TerminalSurface
|
|
1398
|
-
|
|
1399
|
-
lineHeight={lineHeight}
|
|
1400
|
-
affordances={false}
|
|
1401
|
-
bleed='1ch'
|
|
1402
|
-
className='term-transcript'>
|
|
1403
|
-
<WorkingRow label='Loading…' />
|
|
217
|
+
<TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
|
|
218
|
+
<WorkingRow label="Loading…" />
|
|
1404
219
|
</TerminalSurface>
|
|
1405
220
|
) : (
|
|
1406
|
-
<Loader label=
|
|
221
|
+
<Loader label="Loading session…" />
|
|
1407
222
|
)}
|
|
1408
223
|
</div>
|
|
1409
224
|
</div>
|