@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.
Files changed (43) hide show
  1. package/README.md +3 -2
  2. package/build/{SessionPanel-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
  3. package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
  4. package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
  5. package/build/index.d.mts +309 -53
  6. package/build/index.mjs +534 -397
  7. package/build/index.mjs.map +1 -1
  8. package/build/scoped.css +3547 -0
  9. package/build/workspace.d.mts +10 -1
  10. package/build/workspace.mjs +4 -2
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +15 -7
  13. package/src/components/agent/Composer.tsx +10 -10
  14. package/src/components/agent/ContextDialog.tsx +3 -2
  15. package/src/components/agent/Conversation.tsx +1 -1
  16. package/src/components/agent/McpDialog.tsx +3 -1
  17. package/src/components/agent/Scrubber.tsx +248 -0
  18. package/src/components/agent/SessionBrowser.tsx +113 -293
  19. package/src/components/agent/SessionItem.tsx +538 -0
  20. package/src/components/agent/SessionList.tsx +3 -1
  21. package/src/components/agent/SessionPanel.tsx +112 -21
  22. package/src/components/agent/SessionStatusIcon.tsx +43 -0
  23. package/src/components/agent/SessionSteps.tsx +118 -61
  24. package/src/components/agent/SessionWorkspace.tsx +11 -0
  25. package/src/components/agent/SkillsDialog.tsx +3 -2
  26. package/src/components/agent/StatusBar.tsx +1 -1
  27. package/src/components/agent/Transcript.tsx +147 -73
  28. package/src/components/agent/UsageDialog.tsx +3 -1
  29. package/src/components/agent/scrubber-marks.ts +277 -0
  30. package/src/components/terminal/PermissionPrompt.tsx +3 -0
  31. package/src/components/terminal/QuestionPrompt.tsx +3 -0
  32. package/src/components/terminal/StatusLine.tsx +3 -1
  33. package/src/components/ui/AlertDialog.tsx +23 -20
  34. package/src/components/ui/Dialog.tsx +26 -23
  35. package/src/components/ui/Menu.tsx +20 -17
  36. package/src/components/ui/PortalScope.tsx +27 -0
  37. package/src/components/ui/Select.tsx +19 -16
  38. package/src/components/ui/Tooltip.tsx +13 -10
  39. package/src/index.ts +5 -1
  40. package/src/styles/scoped.entry.css +16 -0
  41. package/src/styles/terminal.css +1 -1
  42. package/src/styles/theme.css +223 -8
  43. 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-content-max-w,48rem)] flex-col gap-2'>
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
- * Hosts must clear their request on a session switch: a stale one replayed at
255
- * remount would open a frame the new transcript cannot answer.
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
- * Terminal theme only: hold the prompt of the turn you are reading at the top
264
- * of the transcript, as the Claude Code CLI does. The **real row** is pinned
265
- * rather than a copy drawn above it, so it lines up with the rows beneath by
266
- * construction see `TranscriptRows`.
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
- // The host asking. Keyed on the nonce, so asking twice for the same agent
552
- // works and so a request that arrives while another frame is open swaps it
553
- // in place rather than being ignored.
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) return
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-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
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-content-max-w,48rem)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
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
- * An **agent** has an identity and work of its own, so it is pressable and
45
- * wears the sub-agent colour. A **task** is something the model described with
46
- * no agent behind it (`isAgentRecord`), so it is inert: there is no frame to
47
- * open, and a row that offered one would show an empty screen.
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
- onSelectSubagent: (toolUseId: string) => void,
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
- return (info.subagents ?? []).map((sub) => ({
70
- key: sub.toolUseId,
71
- label: subagentLabel(sub),
72
- noun: 'agent',
73
- kind: isAgentRecord(sub) ? ('agent' as const) : ('task' as const),
74
- state: stepState(sub.status),
75
- detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
76
- title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
77
- onSelect: () => onSelectSubagent(sub.toolUseId),
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 px-0.5 outline-none',
146
- 'hover:bg-surface-hover hover:text-fg-2',
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. Pressing an **agent** hands the panel over to that
157
- * agent's own work — it is not a session and never becomes one, but it does now
158
- * have a surface (`SessionPanel.openSubagent`). A host that cannot frame one
159
- * falls back to revealing its `Task` row, which was this row's only meaning
160
- * before the takeover existed. A **task** is not pressable at all; see
161
- * {@link Step.kind}.
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 row's header and from each other by a rule rather than by
164
- * indentation: these are a list *inside* the row, and at 11px an indent is not
165
- * enough to say so. The rule is black at 25% so it darkens whatever the row is
166
- * filled with, selected or not, without needing a colour per state.
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({ step, onSelect }: { step: Step; onSelect: () => void }) {
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(shape, 'hover:bg-surface-hover', body)}>
212
- {content}
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-3 shrink-0' />
278
+ return <Dot className='size-[11px] shrink-0' />
222
279
  }
223
280
  switch (state) {
224
281
  case 'running':
225
- return <Spinner className='size-3 shrink-0' />
282
+ return <Spinner className='size-[11px] shrink-0' />
226
283
  case 'failed':
227
- return <CircleAlert className='size-3 shrink-0' />
284
+ return <CircleAlert className='size-[11px] shrink-0' />
228
285
  case 'pending':
229
- return <PauseCircle className='size-3 shrink-0' />
286
+ return <PauseCircle className='size-[11px] shrink-0' />
230
287
  default:
231
- return <Check className='size-3 shrink-0' />
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-[38px] items-baseline gap-2 border-border bg-surface p-1.5',
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