@workerdeck/ui 0.20.0 → 0.22.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-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
- package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
- package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
- package/build/index.d.mts +309 -53
- package/build/index.mjs +534 -397
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +3547 -0
- package/build/workspace.d.mts +10 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +15 -7
- 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/Scrubber.tsx +248 -0
- package/src/components/agent/SessionBrowser.tsx +113 -293
- package/src/components/agent/SessionItem.tsx +538 -0
- package/src/components/agent/SessionList.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +112 -21
- package/src/components/agent/SessionStatusIcon.tsx +43 -0
- package/src/components/agent/SessionSteps.tsx +118 -61
- package/src/components/agent/SessionWorkspace.tsx +11 -0
- package/src/components/agent/SkillsDialog.tsx +3 -2
- package/src/components/agent/StatusBar.tsx +1 -1
- 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/index.ts +5 -1
- package/src/styles/scoped.entry.css +16 -0
- package/src/styles/terminal.css +1 -1
- package/src/styles/theme.css +223 -8
- package/build/SessionPanel-CHktI2CF.mjs.map +0 -1
|
@@ -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
|
)
|
|
@@ -251,8 +251,21 @@ export interface SessionPanelProps {
|
|
|
251
251
|
* need a live closure at the far end. Same shape and same reason as
|
|
252
252
|
* {@link SessionPanelProps.reveal}.
|
|
253
253
|
*
|
|
254
|
-
*
|
|
255
|
-
* remount
|
|
254
|
+
* **Withdrawing the request closes the frame.** The prop going away without a
|
|
255
|
+
* remount is itself a request — "the conversation, plainly" — and it leaves
|
|
256
|
+
* through the same path Back takes, so the reader lands on the Task row they
|
|
257
|
+
* came from. It has to mean that, because a host that keeps its request in
|
|
258
|
+
* route state (the dashboard's `?subagent=`) has exactly one way to say it:
|
|
259
|
+
* clear the search — which is what the sidebar's plain session click already
|
|
260
|
+
* navigated with, and what the browser's Back button re-arrives on. Before
|
|
261
|
+
* this, both were silently ignored and the frame outlived the address that
|
|
262
|
+
* claimed it was gone. Still not a controlled value: the panel enters and
|
|
263
|
+
* leaves frames on its own and *reports* through
|
|
264
|
+
* {@link SessionPanelProps.onSubagentChange}; only a **change** of the prop
|
|
265
|
+
* is a request.
|
|
266
|
+
*
|
|
267
|
+
* Hosts must still clear their request on a session switch: a stale one
|
|
268
|
+
* replayed at remount would open a frame the new transcript cannot answer.
|
|
256
269
|
*
|
|
257
270
|
* Claude-only in practice, and gated by data rather than by a flag — codex and
|
|
258
271
|
* provider sessions have no `parentToolUseId`, so they grow no task blocks and
|
|
@@ -260,10 +273,34 @@ export interface SessionPanelProps {
|
|
|
260
273
|
*/
|
|
261
274
|
openSubagent?: { toolUseId: string; nonce: number }
|
|
262
275
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
276
|
+
* Which sub-agent the panel now has framed, or `undefined` for the session's
|
|
277
|
+
* own conversation — the outward half of
|
|
278
|
+
* {@link SessionPanelProps.openSubagent}, and a *statement* where that one is
|
|
279
|
+
* a *request*. Deliberately not an echo: the panel enters frames the host
|
|
280
|
+
* never asked for (a Task row pressed in the transcript) and leaves them on
|
|
281
|
+
* its own (Back, Escape, a reveal), so a host that tracked only its own
|
|
282
|
+
* requests would be wrong within one click. No nonce, for the same reason —
|
|
283
|
+
* a state that arrives twice is the same state, where a request that arrives
|
|
284
|
+
* twice is two requests.
|
|
285
|
+
*
|
|
286
|
+
* Never fired for a fresh mount's initial `undefined`, and never fired from
|
|
287
|
+
* an unmount. The first would be a lie with consequences: the seeding effect
|
|
288
|
+
* consumes `openSubagent` in the same commit, so for one commit the state is
|
|
289
|
+
* `undefined` even though a frame is about to open, and a host folding
|
|
290
|
+
* reports into route state would clear the very `?subagent=` request the
|
|
291
|
+
* panel is in the middle of honouring. The second lets a panel keyed away on
|
|
292
|
+
* a session switch stomp what the host already believes about the next one.
|
|
293
|
+
* See the notify effect for the mechanics.
|
|
294
|
+
*
|
|
295
|
+
* What the sessions list's secondary selection feeds on: the row of the agent
|
|
296
|
+
* on screen takes the blue and its session's card steps back to grey.
|
|
297
|
+
*/
|
|
298
|
+
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
299
|
+
/**
|
|
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.
|
|
267
304
|
*/
|
|
268
305
|
stickyPrompt?: boolean
|
|
269
306
|
/**
|
|
@@ -373,6 +410,12 @@ export interface SessionPanelProps {
|
|
|
373
410
|
* the client cannot see.
|
|
374
411
|
*/
|
|
375
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
|
|
376
419
|
className?: string
|
|
377
420
|
}
|
|
378
421
|
|
|
@@ -446,6 +489,9 @@ export type SessionVitals = {
|
|
|
446
489
|
* as its "seen" watermark while the panel is actually on screen, and compares
|
|
447
490
|
* against later to know what is new. */
|
|
448
491
|
itemCount: number
|
|
492
|
+
/** Session-cumulative cost in USD. The internal status bar renders this via
|
|
493
|
+
* `formatCost`; an external host needs it to reproduce that reading. */
|
|
494
|
+
totalCostUsd: number
|
|
449
495
|
}
|
|
450
496
|
|
|
451
497
|
/**
|
|
@@ -475,6 +521,7 @@ export function SessionPanel({
|
|
|
475
521
|
scrubberMarks,
|
|
476
522
|
reveal,
|
|
477
523
|
openSubagent,
|
|
524
|
+
onSubagentChange,
|
|
478
525
|
stickyPrompt = false,
|
|
479
526
|
controlsSurface = 'internal',
|
|
480
527
|
onControls,
|
|
@@ -483,6 +530,7 @@ export function SessionPanel({
|
|
|
483
530
|
readOnly = false,
|
|
484
531
|
toolHost,
|
|
485
532
|
cacheTranscript,
|
|
533
|
+
emptyState,
|
|
486
534
|
className,
|
|
487
535
|
}: SessionPanelProps) {
|
|
488
536
|
const external = panelSurface === 'external'
|
|
@@ -548,14 +596,35 @@ export function SessionPanel({
|
|
|
548
596
|
setReturnReveal(undefined)
|
|
549
597
|
}, [sessionId])
|
|
550
598
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
599
|
+
// Every way out of the frame that carries no destination of its own — Back,
|
|
600
|
+
// Escape, a withdrawn request — funnels through here, so they all land the
|
|
601
|
+
// reader on the Task row they entered from. A reveal is the one exit that
|
|
602
|
+
// doesn't: it brought its own destination.
|
|
603
|
+
const leaveSubagent = useCallback(() => {
|
|
604
|
+
setSubagentId((current) => {
|
|
605
|
+
if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
606
|
+
return undefined
|
|
607
|
+
})
|
|
608
|
+
}, [])
|
|
609
|
+
|
|
610
|
+
// The host asking — or withdrawing the ask. Keyed on the nonce, so asking
|
|
611
|
+
// twice for the same agent works — and so a request that arrives while
|
|
612
|
+
// another frame is open swaps it in place rather than being ignored.
|
|
613
|
+
//
|
|
614
|
+
// A withdrawn request (the prop going away without a remount) CLOSES the
|
|
615
|
+
// frame — see the prop's docblock for why a host keeping its request in
|
|
616
|
+
// route state needs that to be true. Two non-cases are worth naming because
|
|
617
|
+
// they look like hazards and are not: on a fresh mount with no request this
|
|
618
|
+
// fires once and `leaveSubagent` finds nothing framed, so first render
|
|
619
|
+
// cannot wipe anything; and a host *echoing* the panel's own report back
|
|
620
|
+
// (the dashboard folding `onSubagentChange` into `?subagent=`) re-arrives
|
|
621
|
+
// with the nonce unchanged, so this effect never re-runs for it — the echo
|
|
622
|
+
// is inert by construction, not by a same-value bail-out.
|
|
554
623
|
const openSubagentNonce = openSubagent?.nonce
|
|
555
624
|
const openSubagentId = openSubagent?.toolUseId
|
|
556
625
|
useEffect(() => {
|
|
557
|
-
if (openSubagentId === undefined)
|
|
558
|
-
setSubagentId(openSubagentId)
|
|
626
|
+
if (openSubagentId === undefined) leaveSubagent()
|
|
627
|
+
else setSubagentId(openSubagentId)
|
|
559
628
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
560
629
|
}, [openSubagentNonce])
|
|
561
630
|
|
|
@@ -569,13 +638,6 @@ export function SessionPanel({
|
|
|
569
638
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
570
639
|
}, [revealNonce])
|
|
571
640
|
|
|
572
|
-
const leaveSubagent = useCallback(() => {
|
|
573
|
-
setSubagentId((current) => {
|
|
574
|
-
if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
575
|
-
return undefined
|
|
576
|
-
})
|
|
577
|
-
}, [])
|
|
578
|
-
|
|
579
641
|
// Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
|
|
580
642
|
// keeps a dialog's own Escape (and the composer's) ahead of it: this is the
|
|
581
643
|
// outermost thing Escape can mean here, so it goes last.
|
|
@@ -589,6 +651,32 @@ export function SessionPanel({
|
|
|
589
651
|
return () => window.removeEventListener('keydown', onKey)
|
|
590
652
|
}, [subagentId, leaveSubagent])
|
|
591
653
|
|
|
654
|
+
// The report out — see the prop's docblock for what it claims. Fired off the
|
|
655
|
+
// STATE rather than from each exit path, because the state is the only place
|
|
656
|
+
// all four ways out and both ways in already meet; a notification hung on
|
|
657
|
+
// the affordances would go stale the first time someone added a fifth.
|
|
658
|
+
//
|
|
659
|
+
// The ref pair is the mount guard, and it is load-bearing. On a deep-linked
|
|
660
|
+
// mount, `subagentId` is `undefined` for the whole first commit — the
|
|
661
|
+
// seeding effect above runs in that same commit and its setState lands in
|
|
662
|
+
// the next one — so a naive `useEffect(..., [subagentId])` reports
|
|
663
|
+
// `undefined` first, the host clears the `?subagent=` it was asked with,
|
|
664
|
+
// and the withdrawal closes the frame the reader just requested. The ref
|
|
665
|
+
// starts at `undefined`, so the first commit is silent by construction —
|
|
666
|
+
// not by effect ordering, which is why this works wherever it sits relative
|
|
667
|
+
// to the seeding effect (and under StrictMode's double mount). No cleanup
|
|
668
|
+
// report on unmount, deliberately: hosts key this panel by session, and a
|
|
669
|
+
// departing panel announcing "nothing framed" would stomp whatever the host
|
|
670
|
+
// already holds for the next one.
|
|
671
|
+
const onSubagentChangeRef = useRef(onSubagentChange)
|
|
672
|
+
onSubagentChangeRef.current = onSubagentChange
|
|
673
|
+
const reportedSubagentId = useRef<string | undefined>(undefined)
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
if (reportedSubagentId.current === subagentId) return
|
|
676
|
+
reportedSubagentId.current = subagentId
|
|
677
|
+
onSubagentChangeRef.current?.(subagentId)
|
|
678
|
+
}, [subagentId])
|
|
679
|
+
|
|
592
680
|
// Catch-up is entered once, from the watermark the embedder handed over, and
|
|
593
681
|
// left when dismissed or when the user sends anything (they are plainly
|
|
594
682
|
// caught up at that point). Snapshotted into state rather than read from the
|
|
@@ -708,6 +796,7 @@ export function SessionPanel({
|
|
|
708
796
|
contextUsage: state.contextUsage,
|
|
709
797
|
rateLimits,
|
|
710
798
|
itemCount: state.items.length,
|
|
799
|
+
totalCostUsd: state.totalCostUsd,
|
|
711
800
|
})
|
|
712
801
|
}, [
|
|
713
802
|
state.status,
|
|
@@ -722,6 +811,7 @@ export function SessionPanel({
|
|
|
722
811
|
state.contextUsage,
|
|
723
812
|
rateLimits,
|
|
724
813
|
state.items.length,
|
|
814
|
+
state.totalCostUsd,
|
|
725
815
|
])
|
|
726
816
|
|
|
727
817
|
// The commands back in. One stable object reading through refs, so an
|
|
@@ -1011,6 +1101,7 @@ export function SessionPanel({
|
|
|
1011
1101
|
reveal={returnReveal ?? reveal}
|
|
1012
1102
|
frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
|
|
1013
1103
|
onOpenSubagent={setSubagentId}
|
|
1104
|
+
emptyState={emptyState}
|
|
1014
1105
|
jumpToRecapRef={jumpToRecap}
|
|
1015
1106
|
repinRef={repinTranscript}
|
|
1016
1107
|
/>
|
|
@@ -1023,7 +1114,7 @@ export function SessionPanel({
|
|
|
1023
1114
|
<div className='px-3 pb-1'>
|
|
1024
1115
|
<div
|
|
1025
1116
|
data-slot='catch-up'
|
|
1026
|
-
className='mx-auto flex w-full max-w-[var(--wd-
|
|
1117
|
+
className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3'>
|
|
1027
1118
|
<span
|
|
1028
1119
|
aria-hidden
|
|
1029
1120
|
className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
|
|
@@ -1292,7 +1383,7 @@ function Notice({
|
|
|
1292
1383
|
<div
|
|
1293
1384
|
role='alert'
|
|
1294
1385
|
className={cn(
|
|
1295
|
-
'mx-auto flex w-full max-w-[var(--wd-
|
|
1386
|
+
'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',
|
|
1296
1387
|
level === 'error'
|
|
1297
1388
|
? 'border-danger/40 bg-danger-bg text-danger'
|
|
1298
1389
|
: 'border-warning/40 bg-warning-bg text-warning',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BellRing, CircleAlert, CircleSlash, Moon, PauseCircle } from 'lucide-react'
|
|
2
|
+
import type { SessionRow } from '@workerdeck/protocol'
|
|
3
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* State as one glyph — a ringing bell when it wants a human, a spinner while it
|
|
8
|
+
* works, a moon when it is only sleeping. Replaces the text badge: in a sidebar
|
|
9
|
+
* the word costs more room than it earns, and the states that matter are the two
|
|
10
|
+
* you can recognise without reading.
|
|
11
|
+
*
|
|
12
|
+
* **It reads `row.state`, not `info.status`, and that distinction is the whole
|
|
13
|
+
* point of the row model.** `sessionState` already folds in the arm this glyph
|
|
14
|
+
* cannot see for itself: a *background* sub-agent outlives its turn by design,
|
|
15
|
+
* so the turn ends, `status` comes to rest at `idle`, and the agent keeps
|
|
16
|
+
* working. Reading the raw status drew a **moon on a row filed under the
|
|
17
|
+
* "Working" header** — the list contradicting itself on one line, which is
|
|
18
|
+
* exactly what a derived view model exists to prevent. The value was in scope
|
|
19
|
+
* and unread.
|
|
20
|
+
*
|
|
21
|
+
* The terminal statuses still come off `info.status`, because `ended` collapses
|
|
22
|
+
* `failed` and `closed` into one bucket and those are worth telling apart here.
|
|
23
|
+
*
|
|
24
|
+
* It lives in its own file rather than inside the dashboard's browser because
|
|
25
|
+
* the extension's cards draw it too, and a second copy there is how the two
|
|
26
|
+
* lists last disagreed about what a parked session looks like.
|
|
27
|
+
*/
|
|
28
|
+
export function SessionStatusIcon({ row, className }: { row: SessionRow; className?: string }) {
|
|
29
|
+
const { info } = row
|
|
30
|
+
const size = cn('size-4 shrink-0', className)
|
|
31
|
+
if (row.state === 'attention') return <BellRing className={cn(size, 'animate-pulse text-warning')} />
|
|
32
|
+
if (row.state === 'working') return <Spinner className={cn(size, 'text-info')} />
|
|
33
|
+
switch (info.status) {
|
|
34
|
+
case 'failed':
|
|
35
|
+
return <CircleAlert className={cn(size, 'text-danger')} />
|
|
36
|
+
case 'closed':
|
|
37
|
+
return <CircleSlash className={cn(size, 'text-fg-4')} />
|
|
38
|
+
case 'parked':
|
|
39
|
+
return <PauseCircle className={cn(size, 'text-fg-3')} />
|
|
40
|
+
default:
|
|
41
|
+
return <Moon className={cn(size, 'text-fg-4')} />
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -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
|
|
@@ -59,23 +65,46 @@ export type Step = {
|
|
|
59
65
|
onSelect: () => void
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The steps under one session, **agents first**.
|
|
70
|
+
*
|
|
71
|
+
* The two kinds do different things when pressed and are worth different
|
|
72
|
+
* amounts of attention: an agent has work of its own to go and read, a task is a
|
|
73
|
+
* marker in a transcript. Interleaved in dispatch order they read as one
|
|
74
|
+
* undifferentiated list, and the rows you can actually open are scattered
|
|
75
|
+
* through it. Grouped, the openable ones are a block at the top and the markers
|
|
76
|
+
* are a tail you can skip.
|
|
77
|
+
*
|
|
78
|
+
* Stable **within** each group, deliberately: dispatch order is the only order
|
|
79
|
+
* these records have that means anything (it is the order the work was started
|
|
80
|
+
* in), so the sort partitions and never reorders inside a partition.
|
|
81
|
+
*
|
|
82
|
+
* `onSelect` is handed the **kind** as well as the id, because the two kinds go
|
|
83
|
+
* to different places — see `SessionItem`, which is what routes them. Passing
|
|
84
|
+
* only the id is what let a task be opened as if it were an agent, and a framed
|
|
85
|
+
* task id matches no items, so the panel drew an **empty agent view**.
|
|
86
|
+
*/
|
|
62
87
|
export function sessionSteps(
|
|
63
88
|
info: SessionInfo,
|
|
64
|
-
|
|
89
|
+
onSelect: (toolUseId: string, kind: Step['kind']) => void,
|
|
65
90
|
): Step[] {
|
|
66
91
|
// The label is protocol's `subagentLabel`, not a spelling of its own: the
|
|
67
92
|
// dashboard and the phone render the same rows from the same records, and two
|
|
68
93
|
// spellings would be two different answers to "which agent is this".
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
const steps = (info.subagents ?? []).map((sub) => {
|
|
95
|
+
const kind = isAgentRecord(sub) ? ('agent' as const) : ('task' as const)
|
|
96
|
+
return {
|
|
97
|
+
key: sub.toolUseId,
|
|
98
|
+
label: subagentLabel(sub),
|
|
99
|
+
noun: 'agent',
|
|
100
|
+
kind,
|
|
101
|
+
state: stepState(sub.status),
|
|
102
|
+
detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
|
|
103
|
+
title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
|
|
104
|
+
onSelect: () => onSelect(sub.toolUseId, kind),
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
return [...steps.filter((s) => s.kind === 'agent'), ...steps.filter((s) => s.kind === 'task')]
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
function stepState(status: SubagentInfo['status']): Step['state'] {
|
|
@@ -142,8 +171,8 @@ export function StepToggle({
|
|
|
142
171
|
onToggle()
|
|
143
172
|
}}
|
|
144
173
|
className={cn(
|
|
145
|
-
'flex shrink-0 items-center gap-0.5 rounded
|
|
146
|
-
'hover:bg-
|
|
174
|
+
'flex shrink-0 items-center gap-0.5 rounded-[4px] py-0.5 pr-1 pl-0.5 outline-none',
|
|
175
|
+
'text-[0.75rem] leading-3 hover:bg-row-hover hover:text-fg-2',
|
|
147
176
|
running > 0 ? 'text-info' : 'text-fg-4',
|
|
148
177
|
)}>
|
|
149
178
|
<Chevron className='size-3' />
|
|
@@ -153,20 +182,58 @@ export function StepToggle({
|
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
/**
|
|
156
|
-
* One step under its session
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
185
|
+
* One step under its session — **pressable, all of them**, and what a press
|
|
186
|
+
* means is what tells the two kinds apart.
|
|
187
|
+
*
|
|
188
|
+
* Pressing an **agent** hands the panel over to that agent's own work
|
|
189
|
+
* (`SessionPanel.openSubagent`): it is not a session and never becomes one, but
|
|
190
|
+
* it has a surface, so it can be the selected thing. Pressing a **task** selects
|
|
191
|
+
* the *session* and travels to that task's marker inside it — a task is a
|
|
192
|
+
* reference to a place in a transcript, not a thing with a screen, so it can be
|
|
193
|
+
* followed but never held.
|
|
194
|
+
*
|
|
195
|
+
* That is a reversal, and a deliberate one. A task used to be inert markup on
|
|
196
|
+
* the argument that "a disabled-looking button still announces itself as one" —
|
|
197
|
+
* correct about the markup, wrong about the premise, because there *was* always
|
|
198
|
+
* somewhere to go and the row simply swallowed the click on its way there. A row
|
|
199
|
+
* that looks like a list item, sits in a list, and does nothing when pressed is
|
|
200
|
+
* the worse lie. So every step answers the pointer and every step answers a
|
|
201
|
+
* press; only what the press does differs.
|
|
162
202
|
*
|
|
163
|
-
* Divided from the
|
|
164
|
-
*
|
|
165
|
-
* enough to say
|
|
166
|
-
*
|
|
203
|
+
* Divided from the card's header by **indentation and its own hit shape**, not
|
|
204
|
+
* by a rule. The rules came first, on the argument that at 11px an indent is not
|
|
205
|
+
* enough to say "list inside a row" — and they were right about the reading and
|
|
206
|
+
* wrong about the cost: a stack of hairlines across every open card turned the
|
|
207
|
+
* list into a ledger, and a rule cannot answer a pointer. A step that lights up
|
|
208
|
+
* under the cursor and fills when it is the one on screen says *list* far more
|
|
209
|
+
* plainly than a line between two of them, and it says it while doing the job
|
|
210
|
+
* the rule could not.
|
|
211
|
+
*
|
|
212
|
+
* **Only an agent can wear the selection**, and `active` is guarded on that here
|
|
213
|
+
* rather than trusted from the caller: a host that hands back a task's key is
|
|
214
|
+
* describing where it navigated, not what it selected, and the row must not
|
|
215
|
+
* paint itself blue for it.
|
|
216
|
+
*
|
|
217
|
+
* The hover is `--row-active` — **alpha, not a flat fill** — because this row
|
|
218
|
+
* has to answer the pointer on three different grounds: a transparent card, a
|
|
219
|
+
* blue one (its session is selected), and a grey one (a sibling agent is). A
|
|
220
|
+
* flat value tuned for any of those is wrong on the other two; a tint darkens or
|
|
221
|
+
* lifts whatever it lands on. It is the one place in the list where the alpha
|
|
222
|
+
* token earns its keep.
|
|
167
223
|
*/
|
|
168
|
-
export function StepRow({
|
|
224
|
+
export function StepRow({
|
|
225
|
+
step,
|
|
226
|
+
active = false,
|
|
227
|
+
onSelect,
|
|
228
|
+
}: {
|
|
229
|
+
step: Step
|
|
230
|
+
/** The panel is showing this step's own work. Ignored for tasks, which cannot
|
|
231
|
+
* be the selected thing — see above. */
|
|
232
|
+
active?: boolean
|
|
233
|
+
onSelect: () => void
|
|
234
|
+
}) {
|
|
169
235
|
const agent = step.kind === 'agent'
|
|
236
|
+
const selected = active && agent
|
|
170
237
|
// Body colour by *kind*, state carried by the icon — the rule the transcript's
|
|
171
238
|
// own `TaskRow` already follows, where the body is green and the marker holds
|
|
172
239
|
// the beat. Green means sub-agent across this product (it is the one hue
|
|
@@ -174,42 +241,32 @@ export function StepRow({ step, onSelect }: { step: Step; onSelect: () => void }
|
|
|
174
241
|
// "running" here would be saying something different from the transcript
|
|
175
242
|
// about the same agent. Failure still outranks it: an alarm is not a category.
|
|
176
243
|
const body = step.state === 'failed' ? 'text-danger' : agent ? 'text-success' : 'text-fg-4'
|
|
177
|
-
const content = (
|
|
178
|
-
<>
|
|
179
|
-
<StepIcon state={step.state} kind={step.kind} />
|
|
180
|
-
<span className='min-w-0 flex-1 truncate'>{step.label}</span>
|
|
181
|
-
{/* The progress reading while it works, and what it cost when it is done.
|
|
182
|
-
Zero draws nothing: `0 tools` beside a thinking agent reads as a stall,
|
|
183
|
-
which is the same call `taskSummary` makes one surface over. */}
|
|
184
|
-
{step.detail ? <span className='shrink-0 tabular-nums text-fg-4'>{step.detail}</span> : null}
|
|
185
|
-
{agent ? <ArrowRight className='size-3 shrink-0 opacity-60' /> : null}
|
|
186
|
-
</>
|
|
187
|
-
)
|
|
188
|
-
const shape =
|
|
189
|
-
'flex w-full items-center gap-2 border-t border-black/25 py-1 pl-3 pr-2 text-left text-label outline-none'
|
|
190
|
-
|
|
191
|
-
// A task is not a button, in the markup and not merely in the styling: there
|
|
192
|
-
// is nothing to press, and a disabled-looking button still announces itself as
|
|
193
|
-
// one. It stops the click all the same — the whole session row is pressable
|
|
194
|
-
// underneath, so falling through would open the session from a row that says
|
|
195
|
-
// it does nothing.
|
|
196
|
-
if (!agent) {
|
|
197
|
-
return (
|
|
198
|
-
<div title={step.title} onClick={(e) => e.stopPropagation()} className={cn(shape, body)}>
|
|
199
|
-
{content}
|
|
200
|
-
</div>
|
|
201
|
-
)
|
|
202
|
-
}
|
|
203
244
|
return (
|
|
204
245
|
<button
|
|
205
246
|
type='button'
|
|
206
247
|
title={step.title}
|
|
248
|
+
aria-current={selected || undefined}
|
|
207
249
|
onClick={(e) => {
|
|
250
|
+
// The whole card is pressable underneath and means "open the session".
|
|
251
|
+
// This row has its own answer — for an agent a different destination,
|
|
252
|
+
// for a task the same one plus a place to land — so it must not also
|
|
253
|
+
// fire the card's.
|
|
208
254
|
e.stopPropagation()
|
|
209
255
|
onSelect()
|
|
210
256
|
}}
|
|
211
|
-
className={cn(
|
|
212
|
-
|
|
257
|
+
className={cn(
|
|
258
|
+
'flex w-full items-center gap-1.5 rounded-[4px] py-1 pr-2.5 pl-3.5',
|
|
259
|
+
'text-left text-micro outline-none',
|
|
260
|
+
selected ? 'bg-row-selected' : 'hover:bg-row-active',
|
|
261
|
+
body,
|
|
262
|
+
)}>
|
|
263
|
+
<StepIcon state={step.state} kind={step.kind} />
|
|
264
|
+
<span className='min-w-0 flex-1 truncate'>{step.label}</span>
|
|
265
|
+
{/* The progress reading while it works, and what it cost when it is done.
|
|
266
|
+
Zero draws nothing: `0 tools` beside a thinking agent reads as a stall,
|
|
267
|
+
which is the same call `taskSummary` makes one surface over. */}
|
|
268
|
+
{step.detail ? <span className='shrink-0 tabular-nums text-fg-4'>{step.detail}</span> : null}
|
|
269
|
+
{agent ? <ArrowRight className='size-3.5 shrink-0 text-fg-4' /> : null}
|
|
213
270
|
</button>
|
|
214
271
|
)
|
|
215
272
|
}
|
|
@@ -218,16 +275,16 @@ function StepIcon({ state, kind }: { state: Step['state']; kind: Step['kind'] })
|
|
|
218
275
|
// A task that is neither running nor failed gets a neutral dot rather than a
|
|
219
276
|
// tick: `done` is a claim about work, and nothing here did any.
|
|
220
277
|
if (kind === 'task' && state !== 'running' && state !== 'failed') {
|
|
221
|
-
return <Dot className='size-
|
|
278
|
+
return <Dot className='size-[11px] shrink-0' />
|
|
222
279
|
}
|
|
223
280
|
switch (state) {
|
|
224
281
|
case 'running':
|
|
225
|
-
return <Spinner className='size-
|
|
282
|
+
return <Spinner className='size-[11px] shrink-0' />
|
|
226
283
|
case 'failed':
|
|
227
|
-
return <CircleAlert className='size-
|
|
284
|
+
return <CircleAlert className='size-[11px] shrink-0' />
|
|
228
285
|
case 'pending':
|
|
229
|
-
return <PauseCircle className='size-
|
|
286
|
+
return <PauseCircle className='size-[11px] shrink-0' />
|
|
230
287
|
default:
|
|
231
|
-
return <Check className='size-
|
|
288
|
+
return <Check className='size-[11px] shrink-0' />
|
|
232
289
|
}
|
|
233
290
|
}
|
|
@@ -43,6 +43,13 @@ export interface SessionWorkspaceProps {
|
|
|
43
43
|
stickyPrompt?: SessionPanelProps['stickyPrompt']
|
|
44
44
|
/** Take the panel body over with one sub-agent's work — see `SessionPanel`. */
|
|
45
45
|
openSubagent?: SessionPanelProps['openSubagent']
|
|
46
|
+
/** Travel to a row in the conversation without framing anything — where a
|
|
47
|
+
* **task** press lands, as opposed to `openSubagent`'s takeover. See
|
|
48
|
+
* `SessionPanel`. */
|
|
49
|
+
reveal?: SessionPanelProps['reveal']
|
|
50
|
+
/** The outward half of `openSubagent`: which sub-agent the panel now has
|
|
51
|
+
* framed, or `undefined` for the conversation — see `SessionPanel`. */
|
|
52
|
+
onSubagentChange?: SessionPanelProps['onSubagentChange']
|
|
46
53
|
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
47
54
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
48
55
|
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
@@ -106,6 +113,8 @@ export function SessionWorkspace({
|
|
|
106
113
|
scrubberMarks,
|
|
107
114
|
stickyPrompt,
|
|
108
115
|
openSubagent,
|
|
116
|
+
reveal,
|
|
117
|
+
onSubagentChange,
|
|
109
118
|
statusPlacement,
|
|
110
119
|
controlsSurface,
|
|
111
120
|
unseen,
|
|
@@ -311,6 +320,8 @@ export function SessionWorkspace({
|
|
|
311
320
|
scrubberMarks={scrubberMarks}
|
|
312
321
|
stickyPrompt={stickyPrompt}
|
|
313
322
|
openSubagent={openSubagent}
|
|
323
|
+
reveal={reveal}
|
|
324
|
+
onSubagentChange={onSubagentChange}
|
|
314
325
|
controlsSurface={controlsSurface}
|
|
315
326
|
statusPlacement={statusPlacement}
|
|
316
327
|
unseen={unseen}
|
|
@@ -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
|