@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.
@@ -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
- * 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
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
- // 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.
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) return
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
- onSelectSubagent: (toolUseId: string) => void,
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
- 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
- }))
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 px-0.5 outline-none',
146
- 'hover:bg-surface-hover hover:text-fg-2',
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. 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}.
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
- * 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.
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({ step, onSelect }: { step: Step; onSelect: () => void }) {
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(shape, 'hover:bg-surface-hover', body)}>
212
- {content}
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-3 shrink-0' />
272
+ return <Dot className='size-[11px] shrink-0' />
222
273
  }
223
274
  switch (state) {
224
275
  case 'running':
225
- return <Spinner className='size-3 shrink-0' />
276
+ return <Spinner className='size-[11px] shrink-0' />
226
277
  case 'failed':
227
- return <CircleAlert className='size-3 shrink-0' />
278
+ return <CircleAlert className='size-[11px] shrink-0' />
228
279
  case 'pending':
229
- return <PauseCircle className='size-3 shrink-0' />
280
+ return <PauseCircle className='size-[11px] shrink-0' />
230
281
  default:
231
- return <Check className='size-3 shrink-0' />
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
- export { SessionStatusIcon } from './components/agent/SessionBrowser.tsx'
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 {
@@ -30,14 +30,48 @@
30
30
  --bg-code: #f5f5f5;
31
31
  --bg-elevated: #ffffffcc;
32
32
 
33
- /* A list row's hover and selected fills. **Alpha, not a flat colour**, because
34
- a row sits on whatever its host paints — `bg-sidebar` in the dashboard,
35
- `--vscode-sideBar-background` in the extension and a flat value tuned for
36
- one of those is invisible on the others. `--bg-surface-hover` was exactly
37
- that mistake: on the dark sidebar (#131313) it lands on #141414, one step
38
- of 255 away, so the hover state did not exist. */
39
- --row-hover: rgb(0 0 0 / 0.05);
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
- --row-hover: rgb(255 255 255 / 0.06);
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