@workerdeck/ui 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/build/{SessionPanel-BjNAnWMF.d.mts → SessionPanel-BobynUo4.d.mts} +75 -5
- package/build/{SessionPanel-DNuPKg5c.mjs → SessionPanel-DUt2VzXG.mjs} +672 -231
- package/build/SessionPanel-DUt2VzXG.mjs.map +1 -0
- package/build/index.d.mts +54 -21
- package/build/index.mjs +7 -6
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +3611 -0
- package/build/workspace.d.mts +7 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +10 -6
- package/src/components/agent/Composer.tsx +10 -10
- package/src/components/agent/ContextDialog.tsx +3 -2
- package/src/components/agent/Conversation.tsx +1 -1
- package/src/components/agent/McpDialog.tsx +3 -1
- package/src/components/agent/Response.tsx +4 -0
- package/src/components/agent/Scrubber.tsx +248 -0
- package/src/components/agent/SessionList.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +132 -16
- package/src/components/agent/SessionSteps.tsx +10 -4
- package/src/components/agent/SessionWorkspace.tsx +8 -0
- package/src/components/agent/SkillsDialog.tsx +3 -2
- package/src/components/agent/StatusBar.tsx +3 -3
- package/src/components/agent/Transcript.tsx +147 -73
- package/src/components/agent/UsageDialog.tsx +3 -1
- package/src/components/agent/scrubber-marks.ts +277 -0
- package/src/components/terminal/PermissionPrompt.tsx +3 -0
- package/src/components/terminal/QuestionPrompt.tsx +3 -0
- package/src/components/terminal/StatusLine.tsx +3 -1
- package/src/components/ui/AlertDialog.tsx +23 -20
- package/src/components/ui/Dialog.tsx +26 -23
- package/src/components/ui/Menu.tsx +20 -17
- package/src/components/ui/PortalScope.tsx +27 -0
- package/src/components/ui/Select.tsx +19 -16
- package/src/components/ui/Tooltip.tsx +13 -10
- package/src/styles/scoped.entry.css +16 -0
- package/src/styles/terminal.css +1 -1
- package/src/styles/theme.css +213 -0
- package/build/SessionPanel-DNuPKg5c.mjs.map +0 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
4
|
+
import { toolInputPreview } from '../../lib/format.ts'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import {
|
|
7
|
+
approvalCluster,
|
|
8
|
+
buildMarks,
|
|
9
|
+
clusterMarks,
|
|
10
|
+
doneLine,
|
|
11
|
+
excerpt,
|
|
12
|
+
KIND_NAME,
|
|
13
|
+
nearestMember,
|
|
14
|
+
type Cluster,
|
|
15
|
+
type Mark,
|
|
16
|
+
} from './scrubber-marks.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The cards variant's overview rail — the terminal scrubber's proportional
|
|
20
|
+
* sibling.
|
|
21
|
+
*
|
|
22
|
+
* Positions are **index space**, not pixel space: mark y is
|
|
23
|
+
* `itemIndex / items.length` of the rail, because proportional text and
|
|
24
|
+
* variable row heights leave the cards transcript with no honest pixel claim
|
|
25
|
+
* (the terminal rail's positions ride the height calculator, which has none
|
|
26
|
+
* here). Less precise, but it still answers the reader's questions — where did
|
|
27
|
+
* I type, where did it fail, where is the approval waiting.
|
|
28
|
+
*
|
|
29
|
+
* And because the positions are approximate, the rail is an **annotation, not
|
|
30
|
+
* a scrollbar**: no drag-to-scrub, no viewport band (meaningless without pixel
|
|
31
|
+
* positions), and the native scrollbar stays. The container never takes the
|
|
32
|
+
* pointer — only the marks do, when interactive — so the scrollbar keeps
|
|
33
|
+
* working straight through the paint. Hover peeks (from `items`, never the DOM
|
|
34
|
+
* — the row a mark describes is usually unmounted) and a click jumps to the
|
|
35
|
+
* mark's item through `onJumpToItem`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ScrubberProps {
|
|
39
|
+
items: readonly TranscriptItem[]
|
|
40
|
+
pendingApprovals: readonly PermissionRequest[]
|
|
41
|
+
/** The catch-up boundary's item index, when the recap is spliced in. */
|
|
42
|
+
recapItemIndex?: number
|
|
43
|
+
/** Bookmarked item indices. Paint only — no store, no set affordance. */
|
|
44
|
+
bookmarks?: readonly number[]
|
|
45
|
+
/** The sub-agent takeover's parent id, when this rail belongs to a frame —
|
|
46
|
+
* what "top level" means to the mark walk (see `buildMarks`). */
|
|
47
|
+
frameParentId?: string
|
|
48
|
+
/** Whether the rail answers the pointer (hover peek, click to jump). False
|
|
49
|
+
* renders passive paint. */
|
|
50
|
+
interactive?: boolean
|
|
51
|
+
/** Jump to an item index. The caller owns the row mapping — item indices are
|
|
52
|
+
* not row indices (`rowIndexForItem`). */
|
|
53
|
+
onJumpToItem?: (itemIndex: number) => void
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function peekContent(
|
|
58
|
+
cluster: Cluster,
|
|
59
|
+
/** The member the pointer resolved to — see {@link nearestMember}. */
|
|
60
|
+
first: Mark | undefined,
|
|
61
|
+
items: readonly TranscriptItem[],
|
|
62
|
+
pendingApprovals: readonly PermissionRequest[],
|
|
63
|
+
): ReactNode {
|
|
64
|
+
const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
|
|
65
|
+
let body: ReactNode = null
|
|
66
|
+
if (cluster.kind === 'approval') {
|
|
67
|
+
const request = pendingApprovals[0]
|
|
68
|
+
body = request ? (
|
|
69
|
+
<>
|
|
70
|
+
<div>{request.title ?? 'Permission required'}</div>
|
|
71
|
+
<div className='wd-scrub-ex' data-tone='muted'>
|
|
72
|
+
{`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
|
|
73
|
+
</div>
|
|
74
|
+
</>
|
|
75
|
+
) : null
|
|
76
|
+
} else if (first && first.kind !== 'recap') {
|
|
77
|
+
const item = items[first.itemIndex]
|
|
78
|
+
if (first.kind === 'turn' || first.kind === 'turnFailed') {
|
|
79
|
+
// The merged mark's peek carries both halves: the message the turn ended
|
|
80
|
+
// on, and the done-line (with its reasons, when it failed).
|
|
81
|
+
const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
|
|
82
|
+
body = (
|
|
83
|
+
<>
|
|
84
|
+
{item?.kind === 'assistant_text' ? <div className='wd-scrub-ex'>{item.text}</div> : null}
|
|
85
|
+
{turn?.kind === 'turn_result' ? (
|
|
86
|
+
<>
|
|
87
|
+
<div data-tone={turn.isError ? 'danger' : 'muted'}>{doneLine(turn)}</div>
|
|
88
|
+
{turn.errors?.map((message, index) => (
|
|
89
|
+
<div key={index} data-tone='danger'>
|
|
90
|
+
{message}
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</>
|
|
94
|
+
) : null}
|
|
95
|
+
</>
|
|
96
|
+
)
|
|
97
|
+
} else if (item) {
|
|
98
|
+
const failure =
|
|
99
|
+
first.kind === 'toolFailed' && item.kind === 'tool_call'
|
|
100
|
+
? item.result?.text.split('\n').find((line) => line.trim() !== '')
|
|
101
|
+
: undefined
|
|
102
|
+
body = (
|
|
103
|
+
<>
|
|
104
|
+
<div
|
|
105
|
+
className='wd-scrub-ex'
|
|
106
|
+
data-tone={
|
|
107
|
+
first.kind === 'error' || first.kind === 'toolFailed' ? 'danger' : undefined
|
|
108
|
+
}>
|
|
109
|
+
{first.kind === 'user' ? <span data-tone='muted'>{'❯ '}</span> : null}
|
|
110
|
+
{excerpt(item)}
|
|
111
|
+
</div>
|
|
112
|
+
{/* Which tool failed is rarely the question — the first non-blank
|
|
113
|
+
line of what it said back is the thing worth peeking at. */}
|
|
114
|
+
{failure ? (
|
|
115
|
+
<div className='wd-scrub-ex' data-tone='danger'>
|
|
116
|
+
{failure}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<div data-tone='muted'>
|
|
126
|
+
{KIND_NAME[first?.kind ?? cluster.kind]}
|
|
127
|
+
{more}
|
|
128
|
+
</div>
|
|
129
|
+
{body}
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function Scrubber({
|
|
135
|
+
items,
|
|
136
|
+
pendingApprovals,
|
|
137
|
+
recapItemIndex,
|
|
138
|
+
bookmarks,
|
|
139
|
+
frameParentId,
|
|
140
|
+
interactive = false,
|
|
141
|
+
onJumpToItem,
|
|
142
|
+
className,
|
|
143
|
+
}: ScrubberProps) {
|
|
144
|
+
const railRef = useRef<HTMLDivElement | null>(null)
|
|
145
|
+
const peekRef = useRef<HTMLDivElement | null>(null)
|
|
146
|
+
const [railH, setRailH] = useState(0)
|
|
147
|
+
const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(
|
|
148
|
+
null,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const element = railRef.current
|
|
153
|
+
if (!element) return
|
|
154
|
+
const observer = new ResizeObserver(() => setRailH(element.clientHeight))
|
|
155
|
+
observer.observe(element)
|
|
156
|
+
setRailH(element.clientHeight)
|
|
157
|
+
return () => observer.disconnect()
|
|
158
|
+
}, [])
|
|
159
|
+
|
|
160
|
+
// A peek is a snapshot of the cluster it was opened on; if the transcript
|
|
161
|
+
// changes underneath (a fixture/session swap, a burst of new items), drop it
|
|
162
|
+
// rather than describe items that no longer exist.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setPeek(null)
|
|
165
|
+
}, [items])
|
|
166
|
+
|
|
167
|
+
// The peek can be taller than the space beside its mark — clamp it into the
|
|
168
|
+
// rail after it has a measured height.
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
const element = peekRef.current
|
|
171
|
+
if (!element || !peek) return
|
|
172
|
+
const height = element.offsetHeight
|
|
173
|
+
const railHeight = railRef.current?.clientHeight ?? 0
|
|
174
|
+
element.style.top = `${Math.max(4, Math.min(railHeight - height - 4, peek.y - height / 2))}px`
|
|
175
|
+
}, [peek])
|
|
176
|
+
|
|
177
|
+
const clusters = useMemo(() => {
|
|
178
|
+
if (railH <= 0) return []
|
|
179
|
+
const built = clusterMarks(
|
|
180
|
+
buildMarks(items, { frameParentId, bookmarks, recapItemIndex }),
|
|
181
|
+
railH,
|
|
182
|
+
items.length,
|
|
183
|
+
)
|
|
184
|
+
if (pendingApprovals.length > 0) built.push(approvalCluster(railH))
|
|
185
|
+
return built
|
|
186
|
+
}, [items, frameParentId, bookmarks, recapItemIndex, pendingApprovals, railH])
|
|
187
|
+
|
|
188
|
+
/** A pointer's y in rail space. */
|
|
189
|
+
const railY = (clientY: number): number =>
|
|
190
|
+
clientY - (railRef.current?.getBoundingClientRect().top ?? 0)
|
|
191
|
+
|
|
192
|
+
const activate = (cluster: Cluster, clientY: number) => {
|
|
193
|
+
if (cluster.kind === 'approval' && cluster.marks.length === 0) {
|
|
194
|
+
// The approval prompt renders below the transcript — the closest an
|
|
195
|
+
// item jump can take the reader is the tail.
|
|
196
|
+
if (items.length > 0) onJumpToItem?.(items.length - 1)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
const mark = nearestMember(cluster, railY(clientY))
|
|
200
|
+
if (mark) onJumpToItem?.(mark.itemIndex)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const showPeek = (cluster: Cluster, clientY: number) => {
|
|
204
|
+
const y = railY(clientY)
|
|
205
|
+
const mark = nearestMember(cluster, y)
|
|
206
|
+
setPeek((previous) =>
|
|
207
|
+
previous && previous.cluster === cluster && previous.mark === mark
|
|
208
|
+
? previous
|
|
209
|
+
: { cluster, mark, y: Math.min(Math.max(y, cluster.y), cluster.y + cluster.h) },
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Paint either way: the rail duplicates information the transcript itself
|
|
214
|
+
// carries, and interactive it is a pointer affordance rather than the
|
|
215
|
+
// scroll surface — the native scrollbar stays the accessible one.
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
ref={railRef}
|
|
219
|
+
className={cn('wd-scrubber', className)}
|
|
220
|
+
data-interactive={interactive || undefined}
|
|
221
|
+
aria-hidden>
|
|
222
|
+
{clusters.map((cluster, index) => (
|
|
223
|
+
<div
|
|
224
|
+
key={index}
|
|
225
|
+
className='wd-scrub-mark'
|
|
226
|
+
data-lane={cluster.lane}
|
|
227
|
+
data-kind={cluster.kind}
|
|
228
|
+
style={{ top: cluster.y, height: cluster.h }}
|
|
229
|
+
{...(interactive
|
|
230
|
+
? {
|
|
231
|
+
onClick: (event) => activate(cluster, event.clientY),
|
|
232
|
+
onPointerEnter: (event) => showPeek(cluster, event.clientY),
|
|
233
|
+
// A chain-merged bar can span the rail; sliding along it
|
|
234
|
+
// retargets the peek to the member under the pointer.
|
|
235
|
+
onPointerMove: (event) => showPeek(cluster, event.clientY),
|
|
236
|
+
onPointerLeave: () => setPeek(null),
|
|
237
|
+
}
|
|
238
|
+
: null)}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
{peek ? (
|
|
242
|
+
<div ref={peekRef} className='wd-scrub-peek' style={{ top: peek.y }}>
|
|
243
|
+
{peekContent(peek.cluster, peek.mark, items, pendingApprovals)}
|
|
244
|
+
</div>
|
|
245
|
+
) : null}
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
@@ -11,9 +11,10 @@ export interface SessionListItemProps {
|
|
|
11
11
|
active?: boolean
|
|
12
12
|
onSelect?: (id: string) => void
|
|
13
13
|
onDelete?: (id: string) => void
|
|
14
|
+
className?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function SessionListItem({ session, active, onSelect, onDelete }: SessionListItemProps) {
|
|
17
|
+
export function SessionListItem({ session, active, onSelect, onDelete, className }: SessionListItemProps) {
|
|
17
18
|
const meta = STATUS_META[session.status]
|
|
18
19
|
return (
|
|
19
20
|
<div
|
|
@@ -22,6 +23,7 @@ export function SessionListItem({ session, active, onSelect, onDelete }: Session
|
|
|
22
23
|
className={cn(
|
|
23
24
|
'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
|
|
24
25
|
active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
|
|
26
|
+
className,
|
|
25
27
|
)}>
|
|
26
28
|
<button
|
|
27
29
|
type='button'
|
|
@@ -98,7 +98,7 @@ function PromptSurface({
|
|
|
98
98
|
}) {
|
|
99
99
|
if (!terminal) {
|
|
100
100
|
return (
|
|
101
|
-
<div className='mx-auto flex w-full max-w-[var(--wd-
|
|
101
|
+
<div className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2'>
|
|
102
102
|
{children}
|
|
103
103
|
</div>
|
|
104
104
|
)
|
|
@@ -297,10 +297,10 @@ export interface SessionPanelProps {
|
|
|
297
297
|
*/
|
|
298
298
|
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
300
|
+
* Hold the prompt of the turn you are reading at the top of the transcript.
|
|
301
|
+
* Works in both variants: the terminal clips to one line (as the CLI does),
|
|
302
|
+
* cards shows a frosted bar. The **real row** is pinned rather than a copy
|
|
303
|
+
* drawn above it, so it lines up with the rows beneath by construction.
|
|
304
304
|
*/
|
|
305
305
|
stickyPrompt?: boolean
|
|
306
306
|
/**
|
|
@@ -410,6 +410,68 @@ export interface SessionPanelProps {
|
|
|
410
410
|
* the client cannot see.
|
|
411
411
|
*/
|
|
412
412
|
cacheTranscript?: boolean
|
|
413
|
+
/**
|
|
414
|
+
* Replaces the default empty state when the transcript has no messages. Pass
|
|
415
|
+
* your product's own onboarding content instead of WorkerDeck's generic
|
|
416
|
+
* "`>_` Tell the agent what to do." placeholder.
|
|
417
|
+
*/
|
|
418
|
+
emptyState?: ReactNode
|
|
419
|
+
/**
|
|
420
|
+
* Called when a link in the transcript is clicked. The embedder decides what
|
|
421
|
+
* happens: navigate in-app, open a browser tab, show a confirmation, or
|
|
422
|
+
* suppress.
|
|
423
|
+
*
|
|
424
|
+
* Return `true` (or a truthy value) to indicate the click was handled — the
|
|
425
|
+
* default action (`window.open(href, '_blank')`) is suppressed. Return
|
|
426
|
+
* `false` / `undefined` / nothing to let the browser open the link normally.
|
|
427
|
+
*
|
|
428
|
+
* Absent means "browser default" — links open in a new tab as Streamdown's
|
|
429
|
+
* `target="_blank"` intends. VS Code's webview overrides this through its own
|
|
430
|
+
* native handler (the "allow once / add to allowlist" dialog) and does not
|
|
431
|
+
* need this prop.
|
|
432
|
+
*
|
|
433
|
+
* **Typical embedder patterns:**
|
|
434
|
+
* - Relative URLs → in-app navigation, no confirmation
|
|
435
|
+
* - External URLs → confirmation dialog, or open unconditionally
|
|
436
|
+
* - Suppress all links → `() => true`
|
|
437
|
+
*/
|
|
438
|
+
onLinkClick?: (href: string) => boolean | void
|
|
439
|
+
/**
|
|
440
|
+
* Client-side tool handlers. Each key is a tool name the model can call; the
|
|
441
|
+
* handler receives the model's input and returns a result. The tool's
|
|
442
|
+
* **schema** must be registered server-side (via `tools` on
|
|
443
|
+
* `ProviderRunnerOptions`), but the handler runs here — right where the data
|
|
444
|
+
* the tool needs lives.
|
|
445
|
+
*
|
|
446
|
+
* Shorthand for `toolHost.clientTools`; when both are set, this wins for
|
|
447
|
+
* overlapping names.
|
|
448
|
+
*
|
|
449
|
+
* ```tsx
|
|
450
|
+
* <SessionPanel
|
|
451
|
+
* clientTools={{
|
|
452
|
+
* app_navigate: async (input) => {
|
|
453
|
+
* router.push((input as { path: string }).path)
|
|
454
|
+
* return { value: 'navigated' }
|
|
455
|
+
* },
|
|
456
|
+
* }}
|
|
457
|
+
* />
|
|
458
|
+
* ```
|
|
459
|
+
*/
|
|
460
|
+
clientTools?: Record<string, import('@workerdeck/react').ClientToolHandler>
|
|
461
|
+
/**
|
|
462
|
+
* Base font size in **whole pixels**. Drives the overall scale of everything
|
|
463
|
+
* the panel draws — prompt, output, markdown, status bar — in both variants.
|
|
464
|
+
*
|
|
465
|
+
* Under the terminal theme it sets `--term-font-size` and derives
|
|
466
|
+
* `--term-line` at the CLI's own 13 : 18 ratio (unless {@link terminalMetrics}
|
|
467
|
+
* overrides those individually). Under cards it sets the panel root's
|
|
468
|
+
* `font-size`, which scales every `rem`/`em`-based token the type scale uses.
|
|
469
|
+
*
|
|
470
|
+
* Absent means "platform default": 13 px for the terminal theme, the
|
|
471
|
+
* inherited body size for cards. That is the right choice for a host that has
|
|
472
|
+
* no preference — the panel reads at the size the rest of the app does.
|
|
473
|
+
*/
|
|
474
|
+
fontSize?: number
|
|
413
475
|
className?: string
|
|
414
476
|
}
|
|
415
477
|
|
|
@@ -483,6 +545,9 @@ export type SessionVitals = {
|
|
|
483
545
|
* as its "seen" watermark while the panel is actually on screen, and compares
|
|
484
546
|
* against later to know what is new. */
|
|
485
547
|
itemCount: number
|
|
548
|
+
/** Session-cumulative cost in USD. The internal status bar renders this via
|
|
549
|
+
* `formatCost`; an external host needs it to reproduce that reading. */
|
|
550
|
+
totalCostUsd: number
|
|
486
551
|
}
|
|
487
552
|
|
|
488
553
|
/**
|
|
@@ -520,9 +585,23 @@ export function SessionPanel({
|
|
|
520
585
|
unseen,
|
|
521
586
|
readOnly = false,
|
|
522
587
|
toolHost,
|
|
588
|
+
clientTools,
|
|
523
589
|
cacheTranscript,
|
|
590
|
+
emptyState,
|
|
591
|
+
onLinkClick,
|
|
592
|
+
fontSize,
|
|
524
593
|
className,
|
|
525
594
|
}: SessionPanelProps) {
|
|
595
|
+
// ── Font size resolution ──────────────────────────────────────────────
|
|
596
|
+
// `fontSize` is the panel-wide knob; `terminalMetrics` is the terminal-
|
|
597
|
+
// specific override that predates it. The two compose: `fontSize` provides
|
|
598
|
+
// the default the terminal derives from (13 : 18 ratio), and individual
|
|
599
|
+
// terminalMetrics fields can still override each axis.
|
|
600
|
+
const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
|
|
601
|
+
const effectiveTermLineHeight =
|
|
602
|
+
terminalMetrics?.lineHeight ??
|
|
603
|
+
(fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
|
|
604
|
+
|
|
526
605
|
const external = panelSurface === 'external'
|
|
527
606
|
const statusExternal = statusSurface === 'external'
|
|
528
607
|
// Both non-internal surfaces take the pickers out of the composer, which is
|
|
@@ -704,7 +783,14 @@ export function SessionPanel({
|
|
|
704
783
|
// SAME handle the panel attached with — the bridge asks the first attached
|
|
705
784
|
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
706
785
|
// which for them never comes.
|
|
707
|
-
useToolCallHost(
|
|
786
|
+
useToolCallHost(
|
|
787
|
+
handle,
|
|
788
|
+
toolHost === false
|
|
789
|
+
? { enabled: false }
|
|
790
|
+
: clientTools
|
|
791
|
+
? { ...toolHost, clientTools: { ...toolHost?.clientTools, ...clientTools } }
|
|
792
|
+
: toolHost,
|
|
793
|
+
)
|
|
708
794
|
const terminal = transcriptVariant === 'terminal'
|
|
709
795
|
|
|
710
796
|
// What the strip reads. The frame's items and its spawning call both come from
|
|
@@ -786,6 +872,7 @@ export function SessionPanel({
|
|
|
786
872
|
contextUsage: state.contextUsage,
|
|
787
873
|
rateLimits,
|
|
788
874
|
itemCount: state.items.length,
|
|
875
|
+
totalCostUsd: state.totalCostUsd,
|
|
789
876
|
})
|
|
790
877
|
}, [
|
|
791
878
|
state.status,
|
|
@@ -800,6 +887,7 @@ export function SessionPanel({
|
|
|
800
887
|
state.contextUsage,
|
|
801
888
|
rateLimits,
|
|
802
889
|
state.items.length,
|
|
890
|
+
state.totalCostUsd,
|
|
803
891
|
])
|
|
804
892
|
|
|
805
893
|
// The commands back in. One stable object reading through refs, so an
|
|
@@ -1000,6 +1088,31 @@ export function SessionPanel({
|
|
|
1000
1088
|
/>
|
|
1001
1089
|
)
|
|
1002
1090
|
|
|
1091
|
+
// ── Link click handler ────────────────────────────────────────────────
|
|
1092
|
+
// When onLinkClick is provided, intercept <a> clicks on the panel root so
|
|
1093
|
+
// the embedder controls navigation. Capture phase so it fires before any
|
|
1094
|
+
// default handling. Only installed when the prop is present — a host that
|
|
1095
|
+
// does not provide it (VS Code, the dashboard) gets the browser / webview
|
|
1096
|
+
// default, which is the right thing in both cases.
|
|
1097
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
1098
|
+
useEffect(() => {
|
|
1099
|
+
if (!onLinkClick) return
|
|
1100
|
+
const el = panelRef.current
|
|
1101
|
+
if (!el) return
|
|
1102
|
+
const handler = (e: MouseEvent) => {
|
|
1103
|
+
const anchor = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
|
|
1104
|
+
if (!anchor) return
|
|
1105
|
+
const href = anchor.getAttribute('href')
|
|
1106
|
+
if (!href) return
|
|
1107
|
+
if (onLinkClick(href)) {
|
|
1108
|
+
e.preventDefault()
|
|
1109
|
+
e.stopPropagation()
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
el.addEventListener('click', handler, true)
|
|
1113
|
+
return () => el.removeEventListener('click', handler, true)
|
|
1114
|
+
}, [onLinkClick])
|
|
1115
|
+
|
|
1003
1116
|
// Dead-space clicks land in the composer. Anything the user actually aimed at
|
|
1004
1117
|
// — a control, a link, the end of a drag-selection — keeps its own meaning;
|
|
1005
1118
|
// this only claims what was left over.
|
|
@@ -1024,13 +1137,15 @@ export function SessionPanel({
|
|
|
1024
1137
|
<ToolResultFetchProvider value={loadFullResult}>
|
|
1025
1138
|
<ToolResultImageProvider value={resultImages}>
|
|
1026
1139
|
<div
|
|
1140
|
+
ref={panelRef}
|
|
1027
1141
|
data-slot='session-panel'
|
|
1028
1142
|
// The typeface is a cascade fact, not a React one — one attribute here,
|
|
1029
1143
|
// and the `[data-agent-font]` rule in theme.css does the rest. Nothing
|
|
1030
1144
|
// outside this subtree can pick it up.
|
|
1031
1145
|
data-agent-font={transcriptFont}
|
|
1032
1146
|
onClick={handleClick}
|
|
1033
|
-
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
|
|
1147
|
+
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
|
|
1148
|
+
style={fontSize !== undefined ? { '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties : undefined}>
|
|
1034
1149
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
1035
1150
|
{statusPlacement === 'top' ? statusBar : null}
|
|
1036
1151
|
{protocolMismatch !== undefined ? (
|
|
@@ -1055,8 +1170,8 @@ export function SessionPanel({
|
|
|
1055
1170
|
label={subagentFallbackLabel}
|
|
1056
1171
|
onBack={leaveSubagent}
|
|
1057
1172
|
terminal={terminal}
|
|
1058
|
-
fontSize={
|
|
1059
|
-
lineHeight={
|
|
1173
|
+
fontSize={effectiveTermFontSize}
|
|
1174
|
+
lineHeight={effectiveTermLineHeight}
|
|
1060
1175
|
/>
|
|
1061
1176
|
) : null}
|
|
1062
1177
|
<Transcript
|
|
@@ -1072,8 +1187,8 @@ export function SessionPanel({
|
|
|
1072
1187
|
hostImage={hostImage}
|
|
1073
1188
|
variant={transcriptVariant}
|
|
1074
1189
|
density={transcriptDensity}
|
|
1075
|
-
fontSize={
|
|
1076
|
-
lineHeight={
|
|
1190
|
+
fontSize={effectiveTermFontSize}
|
|
1191
|
+
lineHeight={effectiveTermLineHeight}
|
|
1077
1192
|
affordances={affordances}
|
|
1078
1193
|
stickyPrompt={stickyPrompt}
|
|
1079
1194
|
scrubber={scrubber}
|
|
@@ -1089,6 +1204,7 @@ export function SessionPanel({
|
|
|
1089
1204
|
reveal={returnReveal ?? reveal}
|
|
1090
1205
|
frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
|
|
1091
1206
|
onOpenSubagent={setSubagentId}
|
|
1207
|
+
emptyState={emptyState}
|
|
1092
1208
|
jumpToRecapRef={jumpToRecap}
|
|
1093
1209
|
repinRef={repinTranscript}
|
|
1094
1210
|
/>
|
|
@@ -1101,7 +1217,7 @@ export function SessionPanel({
|
|
|
1101
1217
|
<div className='px-3 pb-1'>
|
|
1102
1218
|
<div
|
|
1103
1219
|
data-slot='catch-up'
|
|
1104
|
-
className='mx-auto flex w-full max-w-[var(--wd-
|
|
1220
|
+
className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3'>
|
|
1105
1221
|
<span
|
|
1106
1222
|
aria-hidden
|
|
1107
1223
|
className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
|
|
@@ -1134,7 +1250,7 @@ export function SessionPanel({
|
|
|
1134
1250
|
session is actually driven. */}
|
|
1135
1251
|
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
1136
1252
|
<div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
|
|
1137
|
-
<PromptSurface terminal={terminal} metrics={
|
|
1253
|
+
<PromptSurface terminal={terminal} metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }} affordances={affordances}>
|
|
1138
1254
|
{state.pendingApprovals.map((request) => {
|
|
1139
1255
|
const isQuestion =
|
|
1140
1256
|
request.toolName === 'AskUserQuestion' &&
|
|
@@ -1202,8 +1318,8 @@ export function SessionPanel({
|
|
|
1202
1318
|
}
|
|
1203
1319
|
layout={controlsExternal ? 'inline' : 'stacked'}
|
|
1204
1320
|
toolbar={controlsExternal ? undefined : sessionControls}
|
|
1205
|
-
fontSize={
|
|
1206
|
-
lineHeight={
|
|
1321
|
+
fontSize={effectiveTermFontSize}
|
|
1322
|
+
lineHeight={effectiveTermLineHeight}
|
|
1207
1323
|
affordances={affordances}
|
|
1208
1324
|
/>
|
|
1209
1325
|
)}
|
|
@@ -1370,7 +1486,7 @@ function Notice({
|
|
|
1370
1486
|
<div
|
|
1371
1487
|
role='alert'
|
|
1372
1488
|
className={cn(
|
|
1373
|
-
'mx-auto flex w-full max-w-[var(--wd-
|
|
1489
|
+
'mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
|
|
1374
1490
|
level === 'error'
|
|
1375
1491
|
? 'border-danger/40 bg-danger-bg text-danger'
|
|
1376
1492
|
: 'border-warning/40 bg-warning-bg text-warning',
|
|
@@ -41,10 +41,16 @@ export type Step = {
|
|
|
41
41
|
/** What one of these is called, for the disclosure's count. */
|
|
42
42
|
noun: string
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Both kinds press. The kind decides **where the press goes**, which is the
|
|
45
|
+
* whole of the distinction.
|
|
46
|
+
*
|
|
47
|
+
* An **agent** has an identity and work of its own, so it wears the sub-agent
|
|
48
|
+
* colour and opens that agent's own frame. A **task** is something the model
|
|
49
|
+
* described with no agent behind it (`isAgentRecord`), so there is no frame:
|
|
50
|
+
* framing its id selects no items and draws an empty screen. What it has
|
|
51
|
+
* instead is a *place* — the spawning call's row in the transcript — so it is
|
|
52
|
+
* muted, never selected, and travels there. See `StepRow` for why an inert
|
|
53
|
+
* row was the worse answer.
|
|
48
54
|
*
|
|
49
55
|
* Two values and not a boolean because the checklist source this shape was
|
|
50
56
|
* drawn for (see above) produces the second kind natively — a to-do is a task
|
|
@@ -53,6 +53,10 @@ export interface SessionWorkspaceProps {
|
|
|
53
53
|
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
54
54
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
55
55
|
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
56
|
+
/** Base font size in whole pixels — see `SessionPanel.fontSize`. */
|
|
57
|
+
fontSize?: SessionPanelProps['fontSize']
|
|
58
|
+
/** Link click handler — see `SessionPanel.onLinkClick`. */
|
|
59
|
+
onLinkClick?: SessionPanelProps['onLinkClick']
|
|
56
60
|
unseen?: SessionPanelProps['unseen']
|
|
57
61
|
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
58
62
|
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
@@ -117,6 +121,8 @@ export function SessionWorkspace({
|
|
|
117
121
|
onSubagentChange,
|
|
118
122
|
statusPlacement,
|
|
119
123
|
controlsSurface,
|
|
124
|
+
fontSize,
|
|
125
|
+
onLinkClick,
|
|
120
126
|
unseen,
|
|
121
127
|
readOnly,
|
|
122
128
|
onVitals,
|
|
@@ -323,6 +329,8 @@ export function SessionWorkspace({
|
|
|
323
329
|
reveal={reveal}
|
|
324
330
|
onSubagentChange={onSubagentChange}
|
|
325
331
|
controlsSurface={controlsSurface}
|
|
332
|
+
fontSize={fontSize}
|
|
333
|
+
onLinkClick={onLinkClick}
|
|
326
334
|
statusPlacement={statusPlacement}
|
|
327
335
|
unseen={unseen}
|
|
328
336
|
readOnly={readOnly}
|
|
@@ -12,6 +12,7 @@ export interface SkillsDialogProps {
|
|
|
12
12
|
/** Insert a skill's opening message into the composer, if the host offers
|
|
13
13
|
* that. Omit and the dialog is read-only. */
|
|
14
14
|
onUse?: (skill: SkillInfo) => void
|
|
15
|
+
className?: string
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/** Where the skill came from. The engine's set is open, so an unrecognised
|
|
@@ -36,7 +37,7 @@ const SCOPE_LABEL: Record<string, string> = {
|
|
|
36
37
|
* Fed from the session's `skills` event rather than a REST route, because that
|
|
37
38
|
* is the channel the engine refreshes on its own when a skill changes on disk.
|
|
38
39
|
*/
|
|
39
|
-
export function SkillsDialog({ skills, open, onOpenChange, onUse }: SkillsDialogProps) {
|
|
40
|
+
export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: SkillsDialogProps) {
|
|
40
41
|
const [selected, setSelected] = useState<string | undefined>()
|
|
41
42
|
const skill = skills?.find((s) => s.name === selected)
|
|
42
43
|
|
|
@@ -47,7 +48,7 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse }: SkillsDialog
|
|
|
47
48
|
if (!next) setSelected(undefined)
|
|
48
49
|
onOpenChange(next)
|
|
49
50
|
}}>
|
|
50
|
-
<DialogContent>
|
|
51
|
+
<DialogContent className={className}>
|
|
51
52
|
<DialogHeader
|
|
52
53
|
title={skill ? (skill.displayName ?? skill.name) : 'Skills'}
|
|
53
54
|
description={
|
|
@@ -211,7 +211,7 @@ export function StatusBar({
|
|
|
211
211
|
// One explicit height, shared with the docked composer above it (see
|
|
212
212
|
// `Composer.tsx`) — the two strips along the foot of the panel read as
|
|
213
213
|
// one piece of chrome, and a pixel of drift between them shows.
|
|
214
|
-
'flex h-[
|
|
214
|
+
'flex h-[var(--wd-status-bar-height)] items-baseline gap-2 border-border bg-surface p-1.5',
|
|
215
215
|
// The rule goes between the bar and the content, so which edge it sits
|
|
216
216
|
// on follows the placement — except under the terminal theme at the
|
|
217
217
|
// foot, where the composer directly above already closes itself with a
|
|
@@ -262,10 +262,10 @@ export function StatusBar({
|
|
|
262
262
|
</span>
|
|
263
263
|
</Slot>
|
|
264
264
|
) : null}
|
|
265
|
-
{controls}
|
|
265
|
+
{controls ? <span className='self-center'>{controls}</span> : null}
|
|
266
266
|
<span className='flex-1' />
|
|
267
267
|
<span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
|
|
268
|
-
{actions}
|
|
268
|
+
{actions ? <span className='self-center'>{actions}</span> : null}
|
|
269
269
|
</div>
|
|
270
270
|
)
|
|
271
271
|
}
|