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