@workerdeck/ui 0.20.0 → 0.21.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-CnU_IJ3-.d.mts → SessionPanel-BjNAnWMF.d.mts} +41 -3
- package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DNuPKg5c.mjs} +20 -12
- package/build/{SessionPanel-CHktI2CF.mjs.map → SessionPanel-DNuPKg5c.mjs.map} +1 -1
- package/build/index.d.mts +256 -33
- package/build/index.mjs +528 -392
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +10 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +9 -5
- package/src/components/agent/SessionBrowser.tsx +113 -293
- package/src/components/agent/SessionItem.tsx +538 -0
- package/src/components/agent/SessionPanel.tsx +92 -14
- package/src/components/agent/SessionStatusIcon.tsx +43 -0
- package/src/components/agent/SessionSteps.tsx +108 -57
- package/src/components/agent/SessionWorkspace.tsx +11 -0
- package/src/index.ts +5 -1
- package/src/styles/theme.css +63 -8
|
@@ -251,14 +251,51 @@ 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
|
|
259
272
|
* no sub-agent rows, and nothing can raise this.
|
|
260
273
|
*/
|
|
261
274
|
openSubagent?: { toolUseId: string; nonce: number }
|
|
275
|
+
/**
|
|
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
|
|
262
299
|
/**
|
|
263
300
|
* Terminal theme only: hold the prompt of the turn you are reading at the top
|
|
264
301
|
* of the transcript, as the Claude Code CLI does. The **real row** is pinned
|
|
@@ -475,6 +512,7 @@ export function SessionPanel({
|
|
|
475
512
|
scrubberMarks,
|
|
476
513
|
reveal,
|
|
477
514
|
openSubagent,
|
|
515
|
+
onSubagentChange,
|
|
478
516
|
stickyPrompt = false,
|
|
479
517
|
controlsSurface = 'internal',
|
|
480
518
|
onControls,
|
|
@@ -548,14 +586,35 @@ export function SessionPanel({
|
|
|
548
586
|
setReturnReveal(undefined)
|
|
549
587
|
}, [sessionId])
|
|
550
588
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
589
|
+
// Every way out of the frame that carries no destination of its own — Back,
|
|
590
|
+
// Escape, a withdrawn request — funnels through here, so they all land the
|
|
591
|
+
// reader on the Task row they entered from. A reveal is the one exit that
|
|
592
|
+
// doesn't: it brought its own destination.
|
|
593
|
+
const leaveSubagent = useCallback(() => {
|
|
594
|
+
setSubagentId((current) => {
|
|
595
|
+
if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
596
|
+
return undefined
|
|
597
|
+
})
|
|
598
|
+
}, [])
|
|
599
|
+
|
|
600
|
+
// The host asking — or withdrawing the ask. Keyed on the nonce, so asking
|
|
601
|
+
// twice for the same agent works — and so a request that arrives while
|
|
602
|
+
// another frame is open swaps it in place rather than being ignored.
|
|
603
|
+
//
|
|
604
|
+
// A withdrawn request (the prop going away without a remount) CLOSES the
|
|
605
|
+
// frame — see the prop's docblock for why a host keeping its request in
|
|
606
|
+
// route state needs that to be true. Two non-cases are worth naming because
|
|
607
|
+
// they look like hazards and are not: on a fresh mount with no request this
|
|
608
|
+
// fires once and `leaveSubagent` finds nothing framed, so first render
|
|
609
|
+
// cannot wipe anything; and a host *echoing* the panel's own report back
|
|
610
|
+
// (the dashboard folding `onSubagentChange` into `?subagent=`) re-arrives
|
|
611
|
+
// with the nonce unchanged, so this effect never re-runs for it — the echo
|
|
612
|
+
// is inert by construction, not by a same-value bail-out.
|
|
554
613
|
const openSubagentNonce = openSubagent?.nonce
|
|
555
614
|
const openSubagentId = openSubagent?.toolUseId
|
|
556
615
|
useEffect(() => {
|
|
557
|
-
if (openSubagentId === undefined)
|
|
558
|
-
setSubagentId(openSubagentId)
|
|
616
|
+
if (openSubagentId === undefined) leaveSubagent()
|
|
617
|
+
else setSubagentId(openSubagentId)
|
|
559
618
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
560
619
|
}, [openSubagentNonce])
|
|
561
620
|
|
|
@@ -569,13 +628,6 @@ export function SessionPanel({
|
|
|
569
628
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
570
629
|
}, [revealNonce])
|
|
571
630
|
|
|
572
|
-
const leaveSubagent = useCallback(() => {
|
|
573
|
-
setSubagentId((current) => {
|
|
574
|
-
if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
575
|
-
return undefined
|
|
576
|
-
})
|
|
577
|
-
}, [])
|
|
578
|
-
|
|
579
631
|
// Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
|
|
580
632
|
// keeps a dialog's own Escape (and the composer's) ahead of it: this is the
|
|
581
633
|
// outermost thing Escape can mean here, so it goes last.
|
|
@@ -589,6 +641,32 @@ export function SessionPanel({
|
|
|
589
641
|
return () => window.removeEventListener('keydown', onKey)
|
|
590
642
|
}, [subagentId, leaveSubagent])
|
|
591
643
|
|
|
644
|
+
// The report out — see the prop's docblock for what it claims. Fired off the
|
|
645
|
+
// STATE rather than from each exit path, because the state is the only place
|
|
646
|
+
// all four ways out and both ways in already meet; a notification hung on
|
|
647
|
+
// the affordances would go stale the first time someone added a fifth.
|
|
648
|
+
//
|
|
649
|
+
// The ref pair is the mount guard, and it is load-bearing. On a deep-linked
|
|
650
|
+
// mount, `subagentId` is `undefined` for the whole first commit — the
|
|
651
|
+
// seeding effect above runs in that same commit and its setState lands in
|
|
652
|
+
// the next one — so a naive `useEffect(..., [subagentId])` reports
|
|
653
|
+
// `undefined` first, the host clears the `?subagent=` it was asked with,
|
|
654
|
+
// and the withdrawal closes the frame the reader just requested. The ref
|
|
655
|
+
// starts at `undefined`, so the first commit is silent by construction —
|
|
656
|
+
// not by effect ordering, which is why this works wherever it sits relative
|
|
657
|
+
// to the seeding effect (and under StrictMode's double mount). No cleanup
|
|
658
|
+
// report on unmount, deliberately: hosts key this panel by session, and a
|
|
659
|
+
// departing panel announcing "nothing framed" would stomp whatever the host
|
|
660
|
+
// already holds for the next one.
|
|
661
|
+
const onSubagentChangeRef = useRef(onSubagentChange)
|
|
662
|
+
onSubagentChangeRef.current = onSubagentChange
|
|
663
|
+
const reportedSubagentId = useRef<string | undefined>(undefined)
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
if (reportedSubagentId.current === subagentId) return
|
|
666
|
+
reportedSubagentId.current = subagentId
|
|
667
|
+
onSubagentChangeRef.current?.(subagentId)
|
|
668
|
+
}, [subagentId])
|
|
669
|
+
|
|
592
670
|
// Catch-up is entered once, from the watermark the embedder handed over, and
|
|
593
671
|
// left when dismissed or when the user sends anything (they are plainly
|
|
594
672
|
// caught up at that point). Snapshotted into state rather than read from the
|
|
@@ -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
|
+
}
|
|
@@ -59,23 +59,46 @@ export type Step = {
|
|
|
59
59
|
onSelect: () => void
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The steps under one session, **agents first**.
|
|
64
|
+
*
|
|
65
|
+
* The two kinds do different things when pressed and are worth different
|
|
66
|
+
* amounts of attention: an agent has work of its own to go and read, a task is a
|
|
67
|
+
* marker in a transcript. Interleaved in dispatch order they read as one
|
|
68
|
+
* undifferentiated list, and the rows you can actually open are scattered
|
|
69
|
+
* through it. Grouped, the openable ones are a block at the top and the markers
|
|
70
|
+
* are a tail you can skip.
|
|
71
|
+
*
|
|
72
|
+
* Stable **within** each group, deliberately: dispatch order is the only order
|
|
73
|
+
* these records have that means anything (it is the order the work was started
|
|
74
|
+
* in), so the sort partitions and never reorders inside a partition.
|
|
75
|
+
*
|
|
76
|
+
* `onSelect` is handed the **kind** as well as the id, because the two kinds go
|
|
77
|
+
* to different places — see `SessionItem`, which is what routes them. Passing
|
|
78
|
+
* only the id is what let a task be opened as if it were an agent, and a framed
|
|
79
|
+
* task id matches no items, so the panel drew an **empty agent view**.
|
|
80
|
+
*/
|
|
62
81
|
export function sessionSteps(
|
|
63
82
|
info: SessionInfo,
|
|
64
|
-
|
|
83
|
+
onSelect: (toolUseId: string, kind: Step['kind']) => void,
|
|
65
84
|
): Step[] {
|
|
66
85
|
// The label is protocol's `subagentLabel`, not a spelling of its own: the
|
|
67
86
|
// dashboard and the phone render the same rows from the same records, and two
|
|
68
87
|
// spellings would be two different answers to "which agent is this".
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
const steps = (info.subagents ?? []).map((sub) => {
|
|
89
|
+
const kind = isAgentRecord(sub) ? ('agent' as const) : ('task' as const)
|
|
90
|
+
return {
|
|
91
|
+
key: sub.toolUseId,
|
|
92
|
+
label: subagentLabel(sub),
|
|
93
|
+
noun: 'agent',
|
|
94
|
+
kind,
|
|
95
|
+
state: stepState(sub.status),
|
|
96
|
+
detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
|
|
97
|
+
title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
|
|
98
|
+
onSelect: () => onSelect(sub.toolUseId, kind),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
return [...steps.filter((s) => s.kind === 'agent'), ...steps.filter((s) => s.kind === 'task')]
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
function stepState(status: SubagentInfo['status']): Step['state'] {
|
|
@@ -142,8 +165,8 @@ export function StepToggle({
|
|
|
142
165
|
onToggle()
|
|
143
166
|
}}
|
|
144
167
|
className={cn(
|
|
145
|
-
'flex shrink-0 items-center gap-0.5 rounded
|
|
146
|
-
'hover:bg-
|
|
168
|
+
'flex shrink-0 items-center gap-0.5 rounded-[4px] py-0.5 pr-1 pl-0.5 outline-none',
|
|
169
|
+
'text-[0.75rem] leading-3 hover:bg-row-hover hover:text-fg-2',
|
|
147
170
|
running > 0 ? 'text-info' : 'text-fg-4',
|
|
148
171
|
)}>
|
|
149
172
|
<Chevron className='size-3' />
|
|
@@ -153,20 +176,58 @@ export function StepToggle({
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
/**
|
|
156
|
-
* One step under its session
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
179
|
+
* One step under its session — **pressable, all of them**, and what a press
|
|
180
|
+
* means is what tells the two kinds apart.
|
|
181
|
+
*
|
|
182
|
+
* Pressing an **agent** hands the panel over to that agent's own work
|
|
183
|
+
* (`SessionPanel.openSubagent`): it is not a session and never becomes one, but
|
|
184
|
+
* it has a surface, so it can be the selected thing. Pressing a **task** selects
|
|
185
|
+
* the *session* and travels to that task's marker inside it — a task is a
|
|
186
|
+
* reference to a place in a transcript, not a thing with a screen, so it can be
|
|
187
|
+
* followed but never held.
|
|
188
|
+
*
|
|
189
|
+
* That is a reversal, and a deliberate one. A task used to be inert markup on
|
|
190
|
+
* the argument that "a disabled-looking button still announces itself as one" —
|
|
191
|
+
* correct about the markup, wrong about the premise, because there *was* always
|
|
192
|
+
* somewhere to go and the row simply swallowed the click on its way there. A row
|
|
193
|
+
* that looks like a list item, sits in a list, and does nothing when pressed is
|
|
194
|
+
* the worse lie. So every step answers the pointer and every step answers a
|
|
195
|
+
* press; only what the press does differs.
|
|
196
|
+
*
|
|
197
|
+
* Divided from the card's header by **indentation and its own hit shape**, not
|
|
198
|
+
* by a rule. The rules came first, on the argument that at 11px an indent is not
|
|
199
|
+
* enough to say "list inside a row" — and they were right about the reading and
|
|
200
|
+
* wrong about the cost: a stack of hairlines across every open card turned the
|
|
201
|
+
* list into a ledger, and a rule cannot answer a pointer. A step that lights up
|
|
202
|
+
* under the cursor and fills when it is the one on screen says *list* far more
|
|
203
|
+
* plainly than a line between two of them, and it says it while doing the job
|
|
204
|
+
* the rule could not.
|
|
162
205
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
206
|
+
* **Only an agent can wear the selection**, and `active` is guarded on that here
|
|
207
|
+
* rather than trusted from the caller: a host that hands back a task's key is
|
|
208
|
+
* describing where it navigated, not what it selected, and the row must not
|
|
209
|
+
* paint itself blue for it.
|
|
210
|
+
*
|
|
211
|
+
* The hover is `--row-active` — **alpha, not a flat fill** — because this row
|
|
212
|
+
* has to answer the pointer on three different grounds: a transparent card, a
|
|
213
|
+
* blue one (its session is selected), and a grey one (a sibling agent is). A
|
|
214
|
+
* flat value tuned for any of those is wrong on the other two; a tint darkens or
|
|
215
|
+
* lifts whatever it lands on. It is the one place in the list where the alpha
|
|
216
|
+
* token earns its keep.
|
|
167
217
|
*/
|
|
168
|
-
export function StepRow({
|
|
218
|
+
export function StepRow({
|
|
219
|
+
step,
|
|
220
|
+
active = false,
|
|
221
|
+
onSelect,
|
|
222
|
+
}: {
|
|
223
|
+
step: Step
|
|
224
|
+
/** The panel is showing this step's own work. Ignored for tasks, which cannot
|
|
225
|
+
* be the selected thing — see above. */
|
|
226
|
+
active?: boolean
|
|
227
|
+
onSelect: () => void
|
|
228
|
+
}) {
|
|
169
229
|
const agent = step.kind === 'agent'
|
|
230
|
+
const selected = active && agent
|
|
170
231
|
// Body colour by *kind*, state carried by the icon — the rule the transcript's
|
|
171
232
|
// own `TaskRow` already follows, where the body is green and the marker holds
|
|
172
233
|
// the beat. Green means sub-agent across this product (it is the one hue
|
|
@@ -174,42 +235,32 @@ export function StepRow({ step, onSelect }: { step: Step; onSelect: () => void }
|
|
|
174
235
|
// "running" here would be saying something different from the transcript
|
|
175
236
|
// about the same agent. Failure still outranks it: an alarm is not a category.
|
|
176
237
|
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
238
|
return (
|
|
204
239
|
<button
|
|
205
240
|
type='button'
|
|
206
241
|
title={step.title}
|
|
242
|
+
aria-current={selected || undefined}
|
|
207
243
|
onClick={(e) => {
|
|
244
|
+
// The whole card is pressable underneath and means "open the session".
|
|
245
|
+
// This row has its own answer — for an agent a different destination,
|
|
246
|
+
// for a task the same one plus a place to land — so it must not also
|
|
247
|
+
// fire the card's.
|
|
208
248
|
e.stopPropagation()
|
|
209
249
|
onSelect()
|
|
210
250
|
}}
|
|
211
|
-
className={cn(
|
|
212
|
-
|
|
251
|
+
className={cn(
|
|
252
|
+
'flex w-full items-center gap-1.5 rounded-[4px] py-1 pr-2.5 pl-3.5',
|
|
253
|
+
'text-left text-micro outline-none',
|
|
254
|
+
selected ? 'bg-row-selected' : 'hover:bg-row-active',
|
|
255
|
+
body,
|
|
256
|
+
)}>
|
|
257
|
+
<StepIcon state={step.state} kind={step.kind} />
|
|
258
|
+
<span className='min-w-0 flex-1 truncate'>{step.label}</span>
|
|
259
|
+
{/* The progress reading while it works, and what it cost when it is done.
|
|
260
|
+
Zero draws nothing: `0 tools` beside a thinking agent reads as a stall,
|
|
261
|
+
which is the same call `taskSummary` makes one surface over. */}
|
|
262
|
+
{step.detail ? <span className='shrink-0 tabular-nums text-fg-4'>{step.detail}</span> : null}
|
|
263
|
+
{agent ? <ArrowRight className='size-3.5 shrink-0 text-fg-4' /> : null}
|
|
213
264
|
</button>
|
|
214
265
|
)
|
|
215
266
|
}
|
|
@@ -218,16 +269,16 @@ function StepIcon({ state, kind }: { state: Step['state']; kind: Step['kind'] })
|
|
|
218
269
|
// A task that is neither running nor failed gets a neutral dot rather than a
|
|
219
270
|
// tick: `done` is a claim about work, and nothing here did any.
|
|
220
271
|
if (kind === 'task' && state !== 'running' && state !== 'failed') {
|
|
221
|
-
return <Dot className='size-
|
|
272
|
+
return <Dot className='size-[11px] shrink-0' />
|
|
222
273
|
}
|
|
223
274
|
switch (state) {
|
|
224
275
|
case 'running':
|
|
225
|
-
return <Spinner className='size-
|
|
276
|
+
return <Spinner className='size-[11px] shrink-0' />
|
|
226
277
|
case 'failed':
|
|
227
|
-
return <CircleAlert className='size-
|
|
278
|
+
return <CircleAlert className='size-[11px] shrink-0' />
|
|
228
279
|
case 'pending':
|
|
229
|
-
return <PauseCircle className='size-
|
|
280
|
+
return <PauseCircle className='size-[11px] shrink-0' />
|
|
230
281
|
default:
|
|
231
|
-
return <Check className='size-
|
|
282
|
+
return <Check className='size-[11px] shrink-0' />
|
|
232
283
|
}
|
|
233
284
|
}
|
|
@@ -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}
|
package/src/index.ts
CHANGED
|
@@ -177,7 +177,11 @@ export {
|
|
|
177
177
|
rowShapeClass,
|
|
178
178
|
type SessionBrowserProps,
|
|
179
179
|
} from './components/agent/SessionBrowser.tsx'
|
|
180
|
-
|
|
180
|
+
// The session card itself — one component, every client. `SessionBrowser` is the
|
|
181
|
+
// dashboard's list *around* it; a host that wants only the card (the VS Code
|
|
182
|
+
// sidebar does, because the filtering chrome above it is native) takes this.
|
|
183
|
+
export { SessionItem, type SessionItemProps } from './components/agent/SessionItem.tsx'
|
|
184
|
+
export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
|
|
181
185
|
// Lifted out of the VS Code sidebar once the dashboard grew a collapsed rail
|
|
182
186
|
// that needs the same glyph — two copies of a trademark set is one too many.
|
|
183
187
|
export {
|
package/src/styles/theme.css
CHANGED
|
@@ -30,14 +30,48 @@
|
|
|
30
30
|
--bg-code: #f5f5f5;
|
|
31
31
|
--bg-elevated: #ffffffcc;
|
|
32
32
|
|
|
33
|
-
/* A list row's
|
|
34
|
-
|
|
35
|
-
`--
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
/* A list row's fills.
|
|
34
|
+
|
|
35
|
+
`--row-hover` is the **flat** value the design specifies (VS Code's
|
|
36
|
+
`list.hoverBackground`), not the alpha it used to be. The alpha existed to
|
|
37
|
+
survive an unknown host surface, and it bought that at the cost of a hover
|
|
38
|
+
that read differently on every one of them; the design draws one hover, so
|
|
39
|
+
this is one colour. The hazard the alpha was guarding against is real and
|
|
40
|
+
has a name — `--bg-surface-hover` on the dark sidebar landed one step of
|
|
41
|
+
255 from the sidebar itself, so the state did not exist — and the guard
|
|
42
|
+
against it now is that both flat values are picked against the sidebar
|
|
43
|
+
fills we actually ship (`--sidebar`), and that the extension repoints all
|
|
44
|
+
three at `--vscode-list-*` rather than inheriting them.
|
|
45
|
+
|
|
46
|
+
`--row-active` stays alpha on purpose: it is the *other three* sidebars'
|
|
47
|
+
selected fill, and those still wear the accent-bar shape `rowShapeClass`
|
|
48
|
+
draws, where the fill is a tint under a bar and not the selection itself.
|
|
49
|
+
|
|
50
|
+
`--row-selected` is new and is the session list's own: a filled card, the
|
|
51
|
+
shape the design gives selection there. Light is a tint carrying dark text;
|
|
52
|
+
dark is VS Code's `list.activeSelectionBackground` carrying light text.
|
|
53
|
+
|
|
54
|
+
`--row-selected-weak` is the **secondary** selection — "this card holds what
|
|
55
|
+
you are looking at, but the thing you are looking at is one row inside it".
|
|
56
|
+
It exists because a session card and its sub-agents are two selections at
|
|
57
|
+
two grains, and the blue can only mean one of them: when a sub-agent is
|
|
58
|
+
open, the blue belongs to that step and the card drops to this. It is VS
|
|
59
|
+
Code's `list.inactiveSelectionBackground`, which is the same idea one
|
|
60
|
+
surface over — selected, but not the thing with focus. Distinct from
|
|
61
|
+
`--row-hover` on purpose: a card that answered the pointer and a card
|
|
62
|
+
holding the open agent are different claims, and a list that painted them
|
|
63
|
+
the same colour would be lying about one of them. */
|
|
64
|
+
--row-hover: #e8e8e8;
|
|
40
65
|
--row-active: rgb(0 0 0 / 0.08);
|
|
66
|
+
--row-selected: #cfe4f7;
|
|
67
|
+
--row-selected-weak: #e4e6f1;
|
|
68
|
+
|
|
69
|
+
/* A count badge that is NOT an alert. The unread badge wears `--accent`
|
|
70
|
+
because unread is a call to look; a plain count (how many sub-agents, how
|
|
71
|
+
many tasks) wears this, because a blue pill that means nothing urgent
|
|
72
|
+
spends the accent on noise. VS Code's `badge.background`. */
|
|
73
|
+
--badge: #c4c4c4;
|
|
74
|
+
--badge-fg: #333333;
|
|
41
75
|
|
|
42
76
|
/* ---------- Borders ---------- */
|
|
43
77
|
--border: #ebebeb;
|
|
@@ -138,8 +172,17 @@
|
|
|
138
172
|
--bg-code: #141414;
|
|
139
173
|
--bg-elevated: #0a0a0acc;
|
|
140
174
|
|
|
141
|
-
|
|
175
|
+
/* See the light block for the whole argument. These two are VS Code's own
|
|
176
|
+
`list.hoverBackground` / `list.activeSelectionBackground`, which is what the
|
|
177
|
+
design was drawn against. */
|
|
178
|
+
--row-hover: #2a2d2e;
|
|
142
179
|
--row-active: rgb(255 255 255 / 0.10);
|
|
180
|
+
--row-selected: #04395e;
|
|
181
|
+
--row-selected-weak: #37373d;
|
|
182
|
+
|
|
183
|
+
/* See the light block. */
|
|
184
|
+
--badge: #616161;
|
|
185
|
+
--badge-fg: #cccccc;
|
|
143
186
|
|
|
144
187
|
/* ---------- Borders ---------- */
|
|
145
188
|
--border: #1f1f1f;
|
|
@@ -291,6 +334,10 @@
|
|
|
291
334
|
--color-surface-hover: var(--bg-surface-hover);
|
|
292
335
|
--color-row-hover: var(--row-hover);
|
|
293
336
|
--color-row-active: var(--row-active);
|
|
337
|
+
--color-row-selected: var(--row-selected);
|
|
338
|
+
--color-row-selected-weak: var(--row-selected-weak);
|
|
339
|
+
--color-badge: var(--badge);
|
|
340
|
+
--color-badge-fg: var(--badge-fg);
|
|
294
341
|
/* Named code-bg (not code) so it can't shadow the --text-code font-size token:
|
|
295
342
|
with a --color-code defined, `text-code` would resolve to the color namespace
|
|
296
343
|
and paint text in the code *background* color — invisible in both themes. */
|
|
@@ -358,6 +405,14 @@
|
|
|
358
405
|
--text-label: 0.75rem;
|
|
359
406
|
--text-label--line-height: 1rem;
|
|
360
407
|
--text-label--letter-spacing: 0.02em;
|
|
408
|
+
/* 11px. The step rows under a session card, and nothing else yet — a size
|
|
409
|
+
below `label` exists so a list *inside* a row can be visibly subordinate to
|
|
410
|
+
it without dropping to a weight nobody can read. Tracking goes NEGATIVE
|
|
411
|
+
here where `label` goes positive: `label` is a small-caps-ish UI word that
|
|
412
|
+
wants air, this is a sentence-shaped run that wants to hold together. */
|
|
413
|
+
--text-micro: 0.6875rem;
|
|
414
|
+
--text-micro--line-height: 0.875rem;
|
|
415
|
+
--text-micro--letter-spacing: -0.005em;
|
|
361
416
|
--text-code: 0.84375rem;
|
|
362
417
|
--text-code--line-height: 1.375rem;
|
|
363
418
|
|