@workerdeck/ui 0.11.0 → 0.13.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-_U8tjX29.mjs → SessionPanel-CKQa4i0Y.mjs} +337 -269
- package/build/SessionPanel-CKQa4i0Y.mjs.map +1 -0
- package/build/{SessionPanel-CyhygZx_.d.mts → SessionPanel-CZMA44NM.d.mts} +73 -2
- package/build/format.d.mts +65 -1
- package/build/format.mjs +118 -1
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +129 -10
- package/build/index.mjs +461 -5
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +36 -1
- package/build/workspace.mjs +16 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +18 -10
- package/src/components/agent/EngineIcon.tsx +97 -0
- package/src/components/agent/Loader.tsx +11 -46
- package/src/components/agent/Message.tsx +10 -6
- package/src/components/agent/QuestionPrompt.tsx +1 -1
- package/src/components/agent/SessionBrowser.tsx +546 -0
- package/src/components/agent/SessionPanel.tsx +89 -14
- package/src/components/agent/SessionWorkspace.tsx +45 -0
- package/src/components/agent/StatusBar.tsx +9 -2
- package/src/components/agent/ToolCallCard.tsx +11 -2
- package/src/components/agent/Transcript.tsx +28 -11
- package/src/components/agent/line-prompt.tsx +2 -2
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +62 -0
- package/src/components/ui/Menu.tsx +1 -1
- package/src/components/ui/Select.tsx +1 -1
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/format.ts +1 -0
- package/src/index.ts +12 -0
- package/src/lib/status.ts +124 -0
- package/src/styles/theme.css +45 -15
- package/build/SessionPanel-_U8tjX29.mjs.map +0 -1
|
@@ -49,7 +49,12 @@ import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
|
49
49
|
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
50
50
|
import { StatusBar } from './StatusBar.tsx'
|
|
51
51
|
import { Transcript } from './Transcript.tsx'
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
TranscriptDensityProvider,
|
|
54
|
+
TranscriptVariantProvider,
|
|
55
|
+
type TranscriptDensity,
|
|
56
|
+
type TranscriptVariant,
|
|
57
|
+
} from './transcript-variant.tsx'
|
|
53
58
|
import { UsageDialog } from './UsageDialog.tsx'
|
|
54
59
|
|
|
55
60
|
export interface SessionPanelProps {
|
|
@@ -93,6 +98,17 @@ export interface SessionPanelProps {
|
|
|
93
98
|
* take the menu, or it has nowhere left to go.
|
|
94
99
|
*/
|
|
95
100
|
statusSurface?: 'internal' | 'external'
|
|
101
|
+
/**
|
|
102
|
+
* Which end of the panel the status bar sits at. Default `top`.
|
|
103
|
+
*
|
|
104
|
+
* `bottom` is the editor convention — VS Code's status bar runs along the
|
|
105
|
+
* foot of the window — and suits a host where the panel *is* the editor area
|
|
106
|
+
* and the chrome above it already belongs to the app. Placement only; the bar
|
|
107
|
+
* is the same bar, with the same `⋯` menu in its trailing slot, so this
|
|
108
|
+
* composes with {@link statusSurface} rather than competing with it (external
|
|
109
|
+
* still means "there isn't one").
|
|
110
|
+
*/
|
|
111
|
+
statusPlacement?: 'top' | 'bottom'
|
|
96
112
|
/** Where `panelSurface: 'external'` routes opens. Absent = the affordances
|
|
97
113
|
* (status-bar clicks, `/mcp`) become inert rather than half-working. */
|
|
98
114
|
onOpenPanel?: (panel: SessionSurfacePanel) => void
|
|
@@ -107,6 +123,14 @@ export interface SessionPanelProps {
|
|
|
107
123
|
* (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
|
|
108
124
|
*/
|
|
109
125
|
transcriptVariant?: TranscriptVariant
|
|
126
|
+
/**
|
|
127
|
+
* How much air the transcript gives each row — `'comfortable'` (default: a
|
|
128
|
+
* blank line between messages, as the Claude Code CLI leaves) or `'compact'`
|
|
129
|
+
* (rows tight against one another). Independent of `transcriptVariant`: the
|
|
130
|
+
* variant follows from the surface, density is the reader's preference, and a
|
|
131
|
+
* dock is allowed to be roomy.
|
|
132
|
+
*/
|
|
133
|
+
transcriptDensity?: TranscriptDensity
|
|
110
134
|
/**
|
|
111
135
|
* Where the session's own controls — model and permission mode — live.
|
|
112
136
|
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
@@ -148,6 +172,22 @@ export interface SessionPanelProps {
|
|
|
148
172
|
* the number to remember through `SessionVitals.itemCount`.
|
|
149
173
|
*/
|
|
150
174
|
unseen?: { itemCount: number; since?: number }
|
|
175
|
+
/**
|
|
176
|
+
* A viewer, not a seat at the session: transcript, status bar and panels as
|
|
177
|
+
* usual, but no composer and no approval prompts.
|
|
178
|
+
*
|
|
179
|
+
* For a surface that is *about* a run rather than in it — the dashboard's job
|
|
180
|
+
* detail, where the session belongs to the queue and typing into it would be a
|
|
181
|
+
* second operator arriving mid-run. Deliberately not "disabled controls": a
|
|
182
|
+
* greyed-out composer says the session is busy, an absent one says this screen
|
|
183
|
+
* does not drive it. The attach is still live and read paths are untouched,
|
|
184
|
+
* so the transcript streams and the file tree browses.
|
|
185
|
+
*
|
|
186
|
+
* It does **not** claim to be an authorization boundary. Anything holding this
|
|
187
|
+
* client can still send; what it removes is the affordance, and the honest
|
|
188
|
+
* enforcement lives on the gateway.
|
|
189
|
+
*/
|
|
190
|
+
readOnly?: boolean
|
|
151
191
|
className?: string
|
|
152
192
|
}
|
|
153
193
|
|
|
@@ -156,6 +196,15 @@ export type SessionControls = {
|
|
|
156
196
|
setModel: (model?: string) => void
|
|
157
197
|
setPermissionMode: (mode: PermissionMode) => void
|
|
158
198
|
interrupt: () => void
|
|
199
|
+
/**
|
|
200
|
+
* Put the caret in the composer.
|
|
201
|
+
*
|
|
202
|
+
* For an embedder whose own chrome is how you arrive at a session — clicking a
|
|
203
|
+
* row in VS Code's sidebar — where revealing the panel and being able to type
|
|
204
|
+
* are the same intention. The panel cannot infer it: from in here, a session
|
|
205
|
+
* appearing looks identical whether someone asked for it or it was restored.
|
|
206
|
+
*/
|
|
207
|
+
focusComposer: () => void
|
|
159
208
|
}
|
|
160
209
|
|
|
161
210
|
/** Everything a click can mean other than "put the caret in the composer".
|
|
@@ -229,13 +278,16 @@ export function SessionPanel({
|
|
|
229
278
|
header,
|
|
230
279
|
panelSurface = 'internal',
|
|
231
280
|
statusSurface = 'internal',
|
|
281
|
+
statusPlacement = 'top',
|
|
232
282
|
onOpenPanel,
|
|
233
283
|
onVitals,
|
|
234
284
|
transcriptVariant = 'cards',
|
|
285
|
+
transcriptDensity = 'comfortable',
|
|
235
286
|
controlsSurface = 'internal',
|
|
236
287
|
onControls,
|
|
237
288
|
focusComposerOnClick = false,
|
|
238
289
|
unseen,
|
|
290
|
+
readOnly = false,
|
|
239
291
|
className,
|
|
240
292
|
}: SessionPanelProps) {
|
|
241
293
|
const external = panelSurface === 'external'
|
|
@@ -358,6 +410,7 @@ export function SessionPanel({
|
|
|
358
410
|
setModel: (model) => setters.current.setModel(model),
|
|
359
411
|
setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
|
|
360
412
|
interrupt: () => setters.current.interrupt(),
|
|
413
|
+
focusComposer: () => composerRef.current?.focus(),
|
|
361
414
|
})
|
|
362
415
|
useEffect(() => {
|
|
363
416
|
const handler = onControlsRef.current
|
|
@@ -479,11 +532,26 @@ export function SessionPanel({
|
|
|
479
532
|
const menu = external ? null : actionsMenu
|
|
480
533
|
const headerTakesActions = typeof header === 'function'
|
|
481
534
|
|
|
535
|
+
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
536
|
+
// three open handlers, and two copies of that in the tree would be two things
|
|
537
|
+
// to keep in step.
|
|
538
|
+
const statusBar = statusExternal ? null : (
|
|
539
|
+
<StatusBar
|
|
540
|
+
state={state}
|
|
541
|
+
connection={connection}
|
|
542
|
+
placement={statusPlacement}
|
|
543
|
+
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
544
|
+
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
545
|
+
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
546
|
+
actions={headerTakesActions ? undefined : menu}
|
|
547
|
+
/>
|
|
548
|
+
)
|
|
549
|
+
|
|
482
550
|
// Dead-space clicks land in the composer. Anything the user actually aimed at
|
|
483
551
|
// — a control, a link, the end of a drag-selection — keeps its own meaning;
|
|
484
552
|
// this only claims what was left over.
|
|
485
553
|
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
486
|
-
if (!focusComposerOnClick) return
|
|
554
|
+
if (!focusComposerOnClick || readOnly) return
|
|
487
555
|
const target = event.target as HTMLElement | null
|
|
488
556
|
if (target?.closest(INTERACTIVE)) return
|
|
489
557
|
if (window.getSelection()?.isCollapsed === false) return
|
|
@@ -495,21 +563,13 @@ export function SessionPanel({
|
|
|
495
563
|
// and question prompts live outside the scroller but are line items in the
|
|
496
564
|
// same run, and they read `useLines()` like every other row.
|
|
497
565
|
<TranscriptVariantProvider value={transcriptVariant}>
|
|
566
|
+
<TranscriptDensityProvider value={transcriptDensity}>
|
|
498
567
|
<div
|
|
499
568
|
data-slot='session-panel'
|
|
500
569
|
onClick={handleClick}
|
|
501
570
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
502
571
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
503
|
-
{
|
|
504
|
-
<StatusBar
|
|
505
|
-
state={state}
|
|
506
|
-
connection={connection}
|
|
507
|
-
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
508
|
-
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
509
|
-
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
510
|
-
actions={headerTakesActions ? undefined : menu}
|
|
511
|
-
/>
|
|
512
|
-
)}
|
|
572
|
+
{statusPlacement === 'top' ? statusBar : null}
|
|
513
573
|
{protocolMismatch !== undefined ? (
|
|
514
574
|
<Notice level='warning'>
|
|
515
575
|
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
|
|
@@ -528,6 +588,7 @@ export function SessionPanel({
|
|
|
528
588
|
canBrowseFiles={hostFiles.available}
|
|
529
589
|
hostImage={hostImage}
|
|
530
590
|
variant={transcriptVariant}
|
|
591
|
+
density={transcriptDensity}
|
|
531
592
|
catchUp={
|
|
532
593
|
catchUp && newCount > 0
|
|
533
594
|
? { from: catchUp.itemCount, since: catchUp.since }
|
|
@@ -543,7 +604,12 @@ export function SessionPanel({
|
|
|
543
604
|
<div
|
|
544
605
|
data-slot='catch-up'
|
|
545
606
|
className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
|
|
546
|
-
<span
|
|
607
|
+
<span
|
|
608
|
+
aria-hidden
|
|
609
|
+
className={cn(
|
|
610
|
+
'select-none',
|
|
611
|
+
transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
|
|
612
|
+
)}>
|
|
547
613
|
※
|
|
548
614
|
</span>
|
|
549
615
|
<span className='min-w-0 flex-1 truncate'>
|
|
@@ -568,7 +634,10 @@ export function SessionPanel({
|
|
|
568
634
|
{/* An engine with no approval channel never raises these, but a stale
|
|
569
635
|
pending request from a replayed log would still render — the record is
|
|
570
636
|
the authority on whether an approval UI means anything here. */}
|
|
571
|
-
{
|
|
637
|
+
{/* A read-only surface has no answer to give: the status bar still says
|
|
638
|
+
`awaiting approval`, and the place that can act on it is wherever the
|
|
639
|
+
session is actually driven. */}
|
|
640
|
+
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
572
641
|
<div className='px-3 pb-2'>
|
|
573
642
|
<div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
|
|
574
643
|
{state.pendingApprovals.map((request) =>
|
|
@@ -592,6 +661,7 @@ export function SessionPanel({
|
|
|
592
661
|
</div>
|
|
593
662
|
</div>
|
|
594
663
|
) : null}
|
|
664
|
+
{readOnly ? null : (
|
|
595
665
|
<Composer
|
|
596
666
|
ref={composerRef}
|
|
597
667
|
onSend={handleSend}
|
|
@@ -636,6 +706,10 @@ export function SessionPanel({
|
|
|
636
706
|
)
|
|
637
707
|
}
|
|
638
708
|
/>
|
|
709
|
+
)}
|
|
710
|
+
{/* Below the composer, along the foot of the panel — the editor
|
|
711
|
+
convention. Last in the flex column, so it is the bottom edge. */}
|
|
712
|
+
{statusPlacement === 'bottom' ? statusBar : null}
|
|
639
713
|
|
|
640
714
|
{/* The internal dialog surface. The external one renders none of these —
|
|
641
715
|
the embedder hosts equivalent surfaces and is handed the intents. */}
|
|
@@ -686,6 +760,7 @@ export function SessionPanel({
|
|
|
686
760
|
</>
|
|
687
761
|
) : null}
|
|
688
762
|
</div>
|
|
763
|
+
</TranscriptDensityProvider>
|
|
689
764
|
</TranscriptVariantProvider>
|
|
690
765
|
)
|
|
691
766
|
}
|
|
@@ -24,10 +24,35 @@ export interface SessionWorkspaceProps {
|
|
|
24
24
|
/** Passed straight through to {@link SessionPanel} — including the render-prop
|
|
25
25
|
* form that claims the session-actions menu. */
|
|
26
26
|
header?: SessionPanelProps['header']
|
|
27
|
+
/**
|
|
28
|
+
* Panel seams the workspace does not interpret, forwarded verbatim.
|
|
29
|
+
*
|
|
30
|
+
* They are listed rather than spread so the workspace stays explicit about
|
|
31
|
+
* what it passes on: the panel owns the session's one attach, and a seam that
|
|
32
|
+
* silently arrived here would be a second place to look for why a session
|
|
33
|
+
* renders the way it does.
|
|
34
|
+
*/
|
|
35
|
+
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
|
+
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
37
|
+
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
38
|
+
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
39
|
+
unseen?: SessionPanelProps['unseen']
|
|
40
|
+
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
41
|
+
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
42
|
+
* a run's files is the point of a read-only view. */
|
|
43
|
+
readOnly?: SessionPanelProps['readOnly']
|
|
44
|
+
onVitals?: SessionPanelProps['onVitals']
|
|
27
45
|
/** Rail width in pixels on first render. */
|
|
28
46
|
defaultRailWidth?: number
|
|
29
47
|
/** Start with the file rail collapsed even on a wide viewport. */
|
|
30
48
|
defaultRailCollapsed?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* The rail moved. Paired with the two defaults so an embedder can persist the
|
|
51
|
+
* layout — the workspace deliberately does not, because *where* to keep it (a
|
|
52
|
+
* Memento, localStorage, a workspace file) is the embedder's call, and a
|
|
53
|
+
* component that picked one would be wrong in the other hosts.
|
|
54
|
+
*/
|
|
55
|
+
onRailChange?: (rail: { width: number; collapsed: boolean }) => void
|
|
31
56
|
className?: string
|
|
32
57
|
}
|
|
33
58
|
|
|
@@ -64,8 +89,15 @@ export function SessionWorkspace({
|
|
|
64
89
|
client,
|
|
65
90
|
sessionId,
|
|
66
91
|
header,
|
|
92
|
+
transcriptVariant,
|
|
93
|
+
transcriptDensity,
|
|
94
|
+
statusPlacement,
|
|
95
|
+
unseen,
|
|
96
|
+
readOnly,
|
|
97
|
+
onVitals,
|
|
67
98
|
defaultRailWidth = 260,
|
|
68
99
|
defaultRailCollapsed,
|
|
100
|
+
onRailChange,
|
|
69
101
|
className,
|
|
70
102
|
}: SessionWorkspaceProps) {
|
|
71
103
|
// The cwd is the tree's root, and it comes from the registry rather than from
|
|
@@ -95,6 +127,13 @@ export function SessionWorkspace({
|
|
|
95
127
|
const wide = useIsWide()
|
|
96
128
|
const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
|
|
97
129
|
const [railWidth, setRailWidth] = useState(defaultRailWidth)
|
|
130
|
+
// Reported rather than stored. Kept in a ref so the effect below fires on a
|
|
131
|
+
// real change instead of on every render an inline callback would cause.
|
|
132
|
+
const onRailChangeRef = useRef(onRailChange)
|
|
133
|
+
onRailChangeRef.current = onRailChange
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
onRailChangeRef.current?.({ width: railWidth, collapsed: railCollapsed })
|
|
136
|
+
}, [railWidth, railCollapsed])
|
|
98
137
|
const [editorHeight, setEditorHeight] = useState(360)
|
|
99
138
|
|
|
100
139
|
// Closing every tab returns the agent to the full column; opening one again
|
|
@@ -231,6 +270,12 @@ export function SessionWorkspace({
|
|
|
231
270
|
client={client}
|
|
232
271
|
sessionId={sessionId}
|
|
233
272
|
header={hoisted}
|
|
273
|
+
transcriptVariant={transcriptVariant}
|
|
274
|
+
transcriptDensity={transcriptDensity}
|
|
275
|
+
statusPlacement={statusPlacement}
|
|
276
|
+
unseen={unseen}
|
|
277
|
+
readOnly={readOnly}
|
|
278
|
+
onVitals={onVitals}
|
|
234
279
|
className='min-h-0 flex-1'
|
|
235
280
|
/>
|
|
236
281
|
</div>
|
|
@@ -28,8 +28,11 @@ export interface StatusBarProps {
|
|
|
28
28
|
onOpenStatus?: () => void
|
|
29
29
|
onOpenContext?: () => void
|
|
30
30
|
onOpenUsage?: () => void
|
|
31
|
-
/** Trailing slot — the session-actions menu,
|
|
31
|
+
/** Trailing slot — the session-actions menu, at the bar's trailing edge. */
|
|
32
32
|
actions?: ReactNode
|
|
33
|
+
/** Which edge the bar sits on, so its separating rule goes on the other side.
|
|
34
|
+
* Placement is the panel's decision; this only styles it. */
|
|
35
|
+
placement?: 'top' | 'bottom'
|
|
33
36
|
className?: string
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -149,6 +152,7 @@ export function StatusBar({
|
|
|
149
152
|
onOpenContext,
|
|
150
153
|
onOpenUsage,
|
|
151
154
|
actions,
|
|
155
|
+
placement = 'top',
|
|
152
156
|
className,
|
|
153
157
|
}: StatusBarProps) {
|
|
154
158
|
const meta = STATUS_META[state.status]
|
|
@@ -160,7 +164,10 @@ export function StatusBar({
|
|
|
160
164
|
<div
|
|
161
165
|
data-slot='status-bar'
|
|
162
166
|
className={cn(
|
|
163
|
-
'flex items-center gap-2 border-
|
|
167
|
+
'flex items-center gap-2 border-border bg-surface px-3 py-1.5',
|
|
168
|
+
// The rule goes between the bar and the content, so which edge it sits
|
|
169
|
+
// on follows the placement.
|
|
170
|
+
placement === 'bottom' ? 'border-t' : 'border-b',
|
|
164
171
|
className,
|
|
165
172
|
)}>
|
|
166
173
|
{/* One slot, two meanings: connection trouble wins it, because a session
|
|
@@ -8,6 +8,7 @@ import { cn } from '../../lib/utils.ts'
|
|
|
8
8
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
9
9
|
import { isMutatingTool, toolIcon } from '../../lib/tool-icon.ts'
|
|
10
10
|
import { LINE_INDENT, LinePayload } from './line-prompt.tsx'
|
|
11
|
+
import { usePulse } from './pulse.tsx'
|
|
11
12
|
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
12
13
|
|
|
13
14
|
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
@@ -97,6 +98,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
97
98
|
const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
98
99
|
const badge = STATE_BADGE[status]
|
|
99
100
|
const isError = status === 'failed' || item.result?.isError === true
|
|
101
|
+
// Ticks only while this row is actually running, and only in the variant with a
|
|
102
|
+
// gutter to pulse in — an idle transcript of a hundred settled tools starts no
|
|
103
|
+
// timers at all.
|
|
104
|
+
const pulse = usePulse(lines && badge.busy)
|
|
100
105
|
const Icon = toolIcon(item.name)
|
|
101
106
|
|
|
102
107
|
const resultText = item.result?.text ?? ''
|
|
@@ -162,7 +167,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
162
167
|
? 'text-success'
|
|
163
168
|
: STATE_GLYPH[status]
|
|
164
169
|
}>
|
|
165
|
-
{
|
|
170
|
+
{/* Running: the mark's own pulse, so a working tool row and the
|
|
171
|
+
transcript's working line beat together. Settled: a plain dot,
|
|
172
|
+
which reads as "done" precisely by not moving. */}
|
|
173
|
+
{badge.busy ? pulse : '●'}
|
|
166
174
|
</LineGlyph>
|
|
167
175
|
<span className='min-w-0 flex-1 truncate text-body-sm leading-5 text-fg-3'>
|
|
168
176
|
<span className='font-medium text-fg-1'>{item.name}</span>
|
|
@@ -171,7 +179,8 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
171
179
|
{item.backend && item.backend !== 'server' ? (
|
|
172
180
|
<span className='shrink-0 text-label text-fg-4'>{item.backend}</span>
|
|
173
181
|
) : null}
|
|
174
|
-
{
|
|
182
|
+
{/* No Spinner here: the gutter glyph animates now, and two spinners on
|
|
183
|
+
one row is one too many. `cards` keeps its own — it has no gutter. */}
|
|
175
184
|
{status === 'deferred' ? <Clock className='size-3 shrink-0 self-center text-fg-4' /> : null}
|
|
176
185
|
{isError && !badge.busy ? (
|
|
177
186
|
<span className='shrink-0 text-label text-danger'>error</span>
|
|
@@ -16,8 +16,10 @@ import { SessionEmptyState } from './SessionEmptyState.tsx'
|
|
|
16
16
|
import { ToolCallCard } from './ToolCallCard.tsx'
|
|
17
17
|
import {
|
|
18
18
|
LineGlyph,
|
|
19
|
+
ROW_GAP,
|
|
19
20
|
TranscriptVariantProvider,
|
|
20
21
|
useLines,
|
|
22
|
+
type TranscriptDensity,
|
|
21
23
|
type TranscriptVariant,
|
|
22
24
|
} from './transcript-variant.tsx'
|
|
23
25
|
|
|
@@ -169,7 +171,7 @@ function RecapRow({ line, since }: { line: string; since?: number }) {
|
|
|
169
171
|
if (lines) {
|
|
170
172
|
return (
|
|
171
173
|
<div data-slot='recap' className='flex items-baseline gap-2 py-0.5'>
|
|
172
|
-
<LineGlyph className='text-
|
|
174
|
+
<LineGlyph className='text-fg-3'>※</LineGlyph>
|
|
173
175
|
<span className='min-w-0 flex-1 text-label leading-5 text-fg-3'>
|
|
174
176
|
<span className='text-fg-2'>recap:</span> {text}
|
|
175
177
|
</span>
|
|
@@ -258,9 +260,8 @@ function SentAttachments({
|
|
|
258
260
|
attachments: MessageAttachment[]
|
|
259
261
|
attachmentUrl?: (attachmentId: string) => string
|
|
260
262
|
}) {
|
|
261
|
-
const lines = useLines()
|
|
262
263
|
return (
|
|
263
|
-
<div className=
|
|
264
|
+
<div className='mb-1 flex flex-wrap justify-start gap-1.5'>
|
|
264
265
|
{attachments.map((attachment) => {
|
|
265
266
|
const href = attachmentUrl?.(attachment.id)
|
|
266
267
|
return attachment.mediaType.startsWith('image/') && href ? (
|
|
@@ -339,6 +340,7 @@ function TranscriptRows({
|
|
|
339
340
|
boundary,
|
|
340
341
|
since,
|
|
341
342
|
lines,
|
|
343
|
+
gap,
|
|
342
344
|
fileUrl,
|
|
343
345
|
attachmentUrl,
|
|
344
346
|
hostImage,
|
|
@@ -348,6 +350,8 @@ function TranscriptRows({
|
|
|
348
350
|
boundary: number | undefined
|
|
349
351
|
since: number | undefined
|
|
350
352
|
lines: boolean
|
|
353
|
+
/** The inter-row gap for this variant and density (`ROW_GAP`). */
|
|
354
|
+
gap: { className?: string; px: number }
|
|
351
355
|
fileUrl?: (path: string) => string
|
|
352
356
|
attachmentUrl?: (attachmentId: string) => string
|
|
353
357
|
hostImage?: (path: string) => Promise<string | undefined>
|
|
@@ -387,7 +391,10 @@ function TranscriptRows({
|
|
|
387
391
|
// a measurement replaces them the moment a row mounts. A lines row is one
|
|
388
392
|
// text line more often than not; cards vary too much for any constant to
|
|
389
393
|
// be right, so that one is merely the order of magnitude.
|
|
390
|
-
|
|
394
|
+
// Plus the gap, which is real height on the same measured element — an
|
|
395
|
+
// estimate that ignored it would make the scrollbar visibly too short on a
|
|
396
|
+
// long transcript before the rows mount.
|
|
397
|
+
estimateSize: () => (lines ? 32 : 100) + gap.px,
|
|
391
398
|
overscan: 8,
|
|
392
399
|
getItemKey: (index) => rows[index].key,
|
|
393
400
|
// Explicit, and left at the default, because the obvious cleanup here is
|
|
@@ -484,13 +491,14 @@ function TranscriptRows({
|
|
|
484
491
|
data-index={virtualRow.index}
|
|
485
492
|
className={cn(
|
|
486
493
|
'absolute inset-x-0 top-0',
|
|
487
|
-
// The
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
+
// The inter-row gap, folded into each row so the measured height
|
|
495
|
+
// carries it: flex `gap` cannot reach absolutely positioned rows,
|
|
496
|
+
// and a pixel constant for the virtualizer's `gap` option would
|
|
497
|
+
// drift from the rem the layout is set in. On this outer wrapper,
|
|
498
|
+
// not the row div, so a nested row's left border still breaks
|
|
499
|
+
// across the gap as it did under flex. Skipped for the first row —
|
|
500
|
+
// a gap above it would be padding, not spacing.
|
|
501
|
+
virtualRow.index > 0 && gap.className,
|
|
494
502
|
)}
|
|
495
503
|
style={{ transform: `translateY(${virtualRow.start}px)` }}>
|
|
496
504
|
{'item' in row ? (
|
|
@@ -541,6 +549,12 @@ export interface TranscriptProps {
|
|
|
541
549
|
* vertical space is scarce. See {@link TranscriptVariant}.
|
|
542
550
|
*/
|
|
543
551
|
variant?: TranscriptVariant
|
|
552
|
+
/**
|
|
553
|
+
* How much air each row gets: `comfortable` (default — a blank line between
|
|
554
|
+
* messages, as the Claude Code CLI does) or `compact`. Independent of
|
|
555
|
+
* {@link TranscriptVariant}. See {@link TranscriptDensity}.
|
|
556
|
+
*/
|
|
557
|
+
density?: TranscriptDensity
|
|
544
558
|
/**
|
|
545
559
|
* Catch-up: `from` is how many items had been seen last time, `since` when
|
|
546
560
|
* that was. A recap row is drawn at that boundary and everything above it is
|
|
@@ -566,11 +580,13 @@ export function Transcript({
|
|
|
566
580
|
canBrowseFiles,
|
|
567
581
|
hostImage,
|
|
568
582
|
variant = 'cards',
|
|
583
|
+
density = 'comfortable',
|
|
569
584
|
catchUp,
|
|
570
585
|
jumpToRecapRef,
|
|
571
586
|
className,
|
|
572
587
|
}: TranscriptProps) {
|
|
573
588
|
const lines = variant === 'lines'
|
|
589
|
+
const gap = ROW_GAP[variant][density]
|
|
574
590
|
const runStartedAt = useRunStart(state.status)
|
|
575
591
|
const following = useSettled(state.items.length, state.status)
|
|
576
592
|
// A boundary at (or past) the end means nothing is new — no row, no dimming.
|
|
@@ -605,6 +621,7 @@ export function Transcript({
|
|
|
605
621
|
boundary={boundary}
|
|
606
622
|
since={catchUp?.since}
|
|
607
623
|
lines={lines}
|
|
624
|
+
gap={gap}
|
|
608
625
|
fileUrl={fileUrl}
|
|
609
626
|
attachmentUrl={attachmentUrl}
|
|
610
627
|
hostImage={hostImage}
|
|
@@ -128,7 +128,7 @@ export function LineOptionList({
|
|
|
128
128
|
'flex w-full items-baseline gap-2 text-left outline-none',
|
|
129
129
|
isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
|
|
130
130
|
)}>
|
|
131
|
-
<LineGlyph className={isFocused ? 'text-
|
|
131
|
+
<LineGlyph className={isFocused ? 'text-fg-1' : undefined}>
|
|
132
132
|
{isFocused ? '❯' : ' '}
|
|
133
133
|
</LineGlyph>
|
|
134
134
|
<span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
|
|
@@ -184,7 +184,7 @@ export function LineInput({
|
|
|
184
184
|
}) {
|
|
185
185
|
return (
|
|
186
186
|
<div className='flex items-baseline gap-2'>
|
|
187
|
-
<LineGlyph className='text-
|
|
187
|
+
<LineGlyph className='text-fg-3'>›</LineGlyph>
|
|
188
188
|
<input
|
|
189
189
|
autoFocus
|
|
190
190
|
value={value}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The brand mark's pulse, as characters — the working marker every surface in the
|
|
5
|
+
* transcript animates.
|
|
6
|
+
*
|
|
7
|
+
* These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
|
|
8
|
+
* state"): a dot, an outline, a semi and a full diamond, built in the SVG from
|
|
9
|
+
* two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
|
|
10
|
+
* clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
|
|
11
|
+
* medium a transcript row actually has.
|
|
12
|
+
*
|
|
13
|
+
* BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
|
|
14
|
+
* *ambiguous width*, so they can render double-width in a terminal under an
|
|
15
|
+
* East-Asian locale and shift every line with them. They are safe wherever the
|
|
16
|
+
* glyph is centred in a fixed-width box, which is what `LineGlyph` is. Anything
|
|
17
|
+
* writing to a real terminal must use the ASCII set instead.
|
|
18
|
+
*/
|
|
19
|
+
export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
|
|
20
|
+
export const PULSE_MS = 150
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The resting state. Stopping the animation lands on the complete mark rather
|
|
24
|
+
* than on a half-drawn frame — the same property that makes the SVG's
|
|
25
|
+
* `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
|
|
26
|
+
*/
|
|
27
|
+
export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
|
|
28
|
+
|
|
29
|
+
/** The OS-level "stop moving things" setting. A spinner is decoration — the word
|
|
30
|
+
* beside it carries the meaning — so honouring this costs nothing. */
|
|
31
|
+
export function usePrefersReducedMotion(): boolean {
|
|
32
|
+
const [reduced, setReduced] = useState(false)
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
35
|
+
if (!query) return
|
|
36
|
+
setReduced(query.matches)
|
|
37
|
+
const onChange = () => setReduced(query.matches)
|
|
38
|
+
query.addEventListener('change', onChange)
|
|
39
|
+
return () => query.removeEventListener('change', onChange)
|
|
40
|
+
}, [])
|
|
41
|
+
return reduced
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The current pulse frame, ticking while `animated`. Callers mount this only
|
|
46
|
+
* while something is actually in flight, so nothing here runs on an idle
|
|
47
|
+
* session; with reduced motion, or when not animating, it holds at rest.
|
|
48
|
+
*/
|
|
49
|
+
export function usePulse(animated: boolean): string {
|
|
50
|
+
const reduced = usePrefersReducedMotion()
|
|
51
|
+
const running = animated && !reduced
|
|
52
|
+
const [frame, setFrame] = useState(0)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!running) return
|
|
55
|
+
const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
|
|
56
|
+
return () => clearInterval(timer)
|
|
57
|
+
}, [running])
|
|
58
|
+
if (!running) return PULSE_REST
|
|
59
|
+
return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
|
|
60
|
+
}
|
|
@@ -39,6 +39,68 @@ export function useLines(): boolean {
|
|
|
39
39
|
return useTranscriptVariant() === 'lines'
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* How much room the transcript gives each row.
|
|
44
|
+
*
|
|
45
|
+
* - `comfortable` — a blank line between messages, which is what the Claude Code
|
|
46
|
+
* CLI does and what the `lines` variant is trying to read like. The default:
|
|
47
|
+
* a transcript is prose before it is a table.
|
|
48
|
+
* - `compact` — rows tight against each other, for a dock where every line of
|
|
49
|
+
* vertical space is contested.
|
|
50
|
+
*
|
|
51
|
+
* Separate from the variant, and deliberately: they answer different questions.
|
|
52
|
+
* The variant decides *how a row is drawn* (boxed or not) and follows from the
|
|
53
|
+
* surface; density decides *how much air is around it* and is a preference the
|
|
54
|
+
* reader holds. Coupling them would mean a dock could not be roomy and a
|
|
55
|
+
* dashboard could not be dense.
|
|
56
|
+
*/
|
|
57
|
+
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
58
|
+
|
|
59
|
+
const DensityContext = createContext<TranscriptDensity>('comfortable')
|
|
60
|
+
|
|
61
|
+
export function TranscriptDensityProvider({
|
|
62
|
+
value,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
value: TranscriptDensity
|
|
66
|
+
children: ReactNode
|
|
67
|
+
}) {
|
|
68
|
+
return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useTranscriptDensity(): TranscriptDensity {
|
|
72
|
+
return useContext(DensityContext)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The gap between two rows, per variant and density — the whole of the density
|
|
77
|
+
* feature, since it is the only vertical spacing between rows that exists.
|
|
78
|
+
*
|
|
79
|
+
* `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
|
|
80
|
+
* part of each row's measured height and no pixel constant is load-bearing.
|
|
81
|
+
* `px` is fed to `estimateSize` alone, where being approximate is the contract:
|
|
82
|
+
* it sets the scrollbar's length before rows mount and is replaced by a real
|
|
83
|
+
* measurement the moment one does.
|
|
84
|
+
*
|
|
85
|
+
* `lines` + `compact` is the only combination with no gap at all: there the
|
|
86
|
+
* row's own `py-0.5` is the entire separation, which is what makes it compact.
|
|
87
|
+
*/
|
|
88
|
+
export const ROW_GAP: Record<
|
|
89
|
+
TranscriptVariant,
|
|
90
|
+
Record<TranscriptDensity, { className?: string; px: number }>
|
|
91
|
+
> = {
|
|
92
|
+
cards: {
|
|
93
|
+
comfortable: { className: 'pt-4', px: 16 },
|
|
94
|
+
compact: { className: 'pt-2', px: 8 },
|
|
95
|
+
},
|
|
96
|
+
lines: {
|
|
97
|
+
// 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
|
|
98
|
+
// line the CLI leaves, arrived at from the line height rather than picked.
|
|
99
|
+
comfortable: { className: 'pt-4', px: 16 },
|
|
100
|
+
compact: { px: 0 },
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
42
104
|
/**
|
|
43
105
|
* The left gutter of a line item: one glyph, fixed width, so every row's text
|
|
44
106
|
* starts on the same column no matter which kind of event it is. Decorative —
|
|
@@ -45,7 +45,7 @@ export const SelectContent: FunctionComponent<
|
|
|
45
45
|
alignItemWithTrigger={alignItemWithTrigger}
|
|
46
46
|
side={side}
|
|
47
47
|
sideOffset={sideOffset}
|
|
48
|
-
className='isolate z-
|
|
48
|
+
className='isolate z-80 outline-none'>
|
|
49
49
|
<SelectPrimitive.Popup
|
|
50
50
|
data-slot='select-content'
|
|
51
51
|
className={cn(
|
|
@@ -8,7 +8,7 @@ export const TooltipContent: FunctionComponent<
|
|
|
8
8
|
TooltipPrimitive.Popup.Props & Pick<TooltipPrimitive.Positioner.Props, 'side' | 'sideOffset'>
|
|
9
9
|
> = ({ className, side = 'top', sideOffset = 6, ...props }) => (
|
|
10
10
|
<TooltipPrimitive.Portal>
|
|
11
|
-
<TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className='isolate z-
|
|
11
|
+
<TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className='isolate z-90'>
|
|
12
12
|
<TooltipPrimitive.Popup
|
|
13
13
|
data-slot='tooltip-content'
|
|
14
14
|
className={cn(
|