@workerdeck/ui 0.16.0 → 0.18.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 (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  PROTOCOL_VERSION,
13
13
  mergeUsage,
14
14
  orderUsageWindows,
15
+ subagentLabel,
15
16
  usageInfos,
16
17
  type ModelOption,
17
18
  type PermissionMode,
@@ -54,6 +55,8 @@ import {
54
55
  type PermissionModeChoice,
55
56
  } from './PermissionModeSelect.tsx'
56
57
  import { PermissionPrompt } from './PermissionPrompt.tsx'
58
+ import { SubagentStrip } from './SubagentStrip.tsx'
59
+ import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
57
60
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
58
61
  import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
59
62
  import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
@@ -63,6 +66,8 @@ import type { TerminalAffordances } from '../terminal/affordances.tsx'
63
66
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
64
67
  import { StatusBar } from './StatusBar.tsx'
65
68
  import { Transcript } from './Transcript.tsx'
69
+ import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
70
+ import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
66
71
  import {
67
72
  TranscriptDensityProvider,
68
73
  TranscriptVariantProvider,
@@ -227,6 +232,33 @@ export interface SessionPanelProps {
227
232
  * client's watermarks; a shared one is session metadata on the gateway).
228
233
  */
229
234
  scrubberMarks?: readonly number[]
235
+ /**
236
+ * Scroll a tool call into view; bump `nonce` to ask again for the same one.
237
+ * See {@link TranscriptProps.reveal} — this is the panel's pass-through, and
238
+ * exists so a surface *outside* the panel (a sessions list showing a session's
239
+ * running sub-agents) can say "take me to that one" without opening a second
240
+ * attach to find out where it is.
241
+ */
242
+ reveal?: { toolUseId: string; nonce: number }
243
+ /**
244
+ * Open a **sub-agent takeover**: the panel body becomes that agent's own work,
245
+ * with a way back. Bump `nonce` to ask again for the same one.
246
+ *
247
+ * A *request*, not a controlled value — the panel owns which agent is open,
248
+ * exactly as it owns `panel`. Dismissal has to work with zero host wiring
249
+ * (Back and Escape are the panel's own affordances), and the two hosts in
250
+ * scope reach this across a postMessage bridge where a controlled prop would
251
+ * need a live closure at the far end. Same shape and same reason as
252
+ * {@link SessionPanelProps.reveal}.
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.
256
+ *
257
+ * Claude-only in practice, and gated by data rather than by a flag — codex and
258
+ * provider sessions have no `parentToolUseId`, so they grow no task blocks and
259
+ * no sub-agent rows, and nothing can raise this.
260
+ */
261
+ openSubagent?: { toolUseId: string; nonce: number }
230
262
  /**
231
263
  * Terminal theme only: hold the prompt of the turn you are reading at the top
232
264
  * of the transcript, as the Claude Code CLI does. The **real row** is pinned
@@ -441,6 +473,8 @@ export function SessionPanel({
441
473
  terminalMetrics,
442
474
  scrubber = false,
443
475
  scrubberMarks,
476
+ reveal,
477
+ openSubagent,
444
478
  stickyPrompt = false,
445
479
  controlsSurface = 'internal',
446
480
  onControls,
@@ -464,6 +498,29 @@ export function SessionPanel({
464
498
  // is not an error channel.
465
499
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
466
500
  const [panel, setPanel] = useState<Panel | undefined>()
501
+ /**
502
+ * The sub-agent takeover: which `Task` call the body is currently showing, or
503
+ * undefined for the conversation.
504
+ *
505
+ * Deliberately **not** a `Panel` member. Those route outward under
506
+ * `panelSurface: 'external'` so a host can draw them natively — and a host
507
+ * cannot draw this one: it is a transcript, and the transcript is in here. It
508
+ * is a mode of the conversation surface, not a screen over it.
509
+ */
510
+ const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
511
+ /**
512
+ * Where to land on the way back — the row of the Task you entered from,
513
+ * asked for through the ordinary reveal seam.
514
+ *
515
+ * The alternative was keeping the main transcript mounted-but-hidden to
516
+ * preserve its scroll offset, which costs two live virtualizers and an
517
+ * absolute-position layout on a panel whose height just changed (the composer
518
+ * came back). Re-revealing is both cheaper and more honest: you return to the
519
+ * row you left from rather than to wherever you happened to be scrolled.
520
+ */
521
+ const [returnReveal, setReturnReveal] = useState<
522
+ { toolUseId: string; nonce: number } | undefined
523
+ >(undefined)
467
524
  const {
468
525
  state,
469
526
  connection,
@@ -479,10 +536,58 @@ export function SessionPanel({
479
536
  setModel,
480
537
  setPermissionMode,
481
538
  reconnectNow,
539
+ loadFullResult,
482
540
  } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
483
541
  // Callers are told to remount on a session switch, but a changed prop must not leave
484
542
  // the previous session's failure on screen.
485
543
  useEffect(() => setProtocolError(undefined), [sessionId])
544
+ // Belt to the remount contract, for the same reason: a takeover left standing
545
+ // across a switch would frame one session's Task id against another's items.
546
+ useEffect(() => {
547
+ setSubagentId(undefined)
548
+ setReturnReveal(undefined)
549
+ }, [sessionId])
550
+
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.
554
+ const openSubagentNonce = openSubagent?.nonce
555
+ const openSubagentId = openSubagent?.toolUseId
556
+ useEffect(() => {
557
+ if (openSubagentId === undefined) return
558
+ setSubagentId(openSubagentId)
559
+ // eslint-disable-next-line react-hooks/exhaustive-deps
560
+ }, [openSubagentNonce])
561
+
562
+ // A *reveal* asked for while framed means the reader wants the conversation:
563
+ // latest intent wins. Leaving the frame up and scrolling something invisible
564
+ // underneath it would be the worst of both.
565
+ const revealNonce = reveal?.nonce
566
+ useEffect(() => {
567
+ if (revealNonce === undefined) return
568
+ setSubagentId(undefined)
569
+ // eslint-disable-next-line react-hooks/exhaustive-deps
570
+ }, [revealNonce])
571
+
572
+ const leaveSubagent = useCallback(() => {
573
+ setSubagentId((current) => {
574
+ if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
575
+ return undefined
576
+ })
577
+ }, [])
578
+
579
+ // Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
580
+ // keeps a dialog's own Escape (and the composer's) ahead of it: this is the
581
+ // outermost thing Escape can mean here, so it goes last.
582
+ useEffect(() => {
583
+ if (subagentId === undefined) return
584
+ const onKey = (event: KeyboardEvent) => {
585
+ if (event.key !== 'Escape' || event.defaultPrevented) return
586
+ leaveSubagent()
587
+ }
588
+ window.addEventListener('keydown', onKey)
589
+ return () => window.removeEventListener('keydown', onKey)
590
+ }, [subagentId, leaveSubagent])
486
591
 
487
592
  // Catch-up is entered once, from the watermark the embedder handed over, and
488
593
  // left when dismissed or when the user sends anything (they are plainly
@@ -523,6 +628,29 @@ export function SessionPanel({
523
628
  // which for them never comes.
524
629
  useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
525
630
  const terminal = transcriptVariant === 'terminal'
631
+
632
+ // What the strip reads. The frame's items and its spawning call both come from
633
+ // the transcript, which is the only complete source: `SessionInfo.subagents`
634
+ // keeps eight settled records and is explicitly not a session's Task history.
635
+ const subagentFrameItems = useMemo(
636
+ () => (subagentId === undefined ? [] : subagentItems(state.items, subagentId)),
637
+ [state.items, subagentId],
638
+ )
639
+ const subagentTask = useMemo(
640
+ () =>
641
+ subagentId === undefined
642
+ ? undefined
643
+ : state.items.find(
644
+ (item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId,
645
+ ),
646
+ [state.items, subagentId],
647
+ )
648
+ // The one thing the rollup is allowed to do: name an agent whose `Task` call
649
+ // the transcript does not have. A label is not content.
650
+ const subagentFallbackLabel = useMemo(() => {
651
+ const record = state.session?.subagents?.find((sub) => sub.toolUseId === subagentId)
652
+ return record ? subagentLabel(record) : 'Sub-agent'
653
+ }, [state.session, subagentId])
526
654
  const capabilities = state.capabilities
527
655
 
528
656
  // Plan usage as the *gateway* knows it, merged over this session's own
@@ -630,6 +758,10 @@ export function SessionPanel({
630
758
  // path, never bytes). Stable and memoized per path: transcript rows re-render
631
759
  // on every delta, and a fresh function would re-fetch each time.
632
760
  const hostImage = useHostImage(client, sessionId, state.producedFiles)
761
+ // The other picture route, and the other store: an image *part* of a tool
762
+ // result, which an opted-in replay delivered as a reference rather than half a
763
+ // megabyte of base64. Bounded, unlike `useHostImage` — see the module.
764
+ const resultImages = useToolResultImages(client, sessionId)
633
765
  const composerRef = useRef<ComposerHandle>(null)
634
766
  // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
635
767
  // recap row into view — the transcript fills it in. See TranscriptProps.
@@ -807,6 +939,12 @@ export function SessionPanel({
807
939
  // run, and they read it from this context rather than a prop chain.
808
940
  <TranscriptVariantProvider value={transcriptVariant}>
809
941
  <TranscriptDensityProvider value={transcriptDensity}>
942
+ {/* The panel owns the session's one attach, so it is the only thing that
943
+ can answer a row asking for the rest of a truncated tool result. Rows
944
+ rendered anywhere else fall back to the context's no-op, which is
945
+ correct for them: nothing truncates a replay they never asked for. */}
946
+ <ToolResultFetchProvider value={loadFullResult}>
947
+ <ToolResultImageProvider value={resultImages}>
810
948
  <div
811
949
  data-slot='session-panel'
812
950
  // The typeface is a cascade fact, not a React one — one attribute here,
@@ -828,7 +966,27 @@ export function SessionPanel({
828
966
  {protocolError}
829
967
  </Notice>
830
968
  ) : null}
969
+ {/* The takeover's one line: who is on screen, and the way back. Above
970
+ the transcript because it is a frame around it, not a row in it —
971
+ and because Back must be reachable without scrolling a stream that
972
+ is still growing. */}
973
+ {subagentId !== undefined ? (
974
+ <SubagentStrip
975
+ task={subagentTask}
976
+ items={subagentFrameItems}
977
+ label={subagentFallbackLabel}
978
+ onBack={leaveSubagent}
979
+ terminal={terminal}
980
+ fontSize={terminalMetrics?.fontSize}
981
+ lineHeight={terminalMetrics?.lineHeight}
982
+ />
983
+ ) : null}
831
984
  <Transcript
985
+ /* A remount, so the frame opens pinned to its own bottom with a fresh
986
+ virtualizer and height epoch — which is the right landing for both
987
+ cases: the live tail of a running agent, and the final report of a
988
+ settled one. */
989
+ key={subagentId ?? 'session'}
832
990
  state={state}
833
991
  fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
834
992
  attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
@@ -848,6 +1006,11 @@ export function SessionPanel({
848
1006
  ? { from: catchUp.itemCount, since: catchUp.since }
849
1007
  : undefined
850
1008
  }
1009
+ /* On the way back, land on the Task you came from — see `returnReveal`.
1010
+ The host's own reveal still wins when it is the newer intent. */
1011
+ reveal={returnReveal ?? reveal}
1012
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
1013
+ onOpenSubagent={setSubagentId}
851
1014
  jumpToRecapRef={jumpToRecap}
852
1015
  repinRef={repinTranscript}
853
1016
  />
@@ -856,7 +1019,7 @@ export function SessionPanel({
856
1019
  itself opens pinned to the newest row. Held with the transcript
857
1020
  while the replay lands — its count is `state.items.length`, which
858
1021
  during the replay is a number visibly climbing toward its answer. */}
859
- {catchUp && newCount > 0 && !replaying ? (
1022
+ {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
860
1023
  <div className='px-3 pb-1'>
861
1024
  <div
862
1025
  data-slot='catch-up'
@@ -934,7 +1097,17 @@ export function SessionPanel({
934
1097
  </PromptSurface>
935
1098
  </div>
936
1099
  ) : null}
937
- {readOnly ? null : (
1100
+ {/* No composer while a sub-agent is framed: you cannot talk to one, and a
1101
+ live-looking input that silently addresses its *parent* is worse than
1102
+ no input at all. Its interrupt goes with it — Back is one press away,
1103
+ and a second interrupt control would be a second thing that stops
1104
+ something the reader is not looking at.
1105
+
1106
+ The approval prompts above deliberately do NOT go with it. A
1107
+ sub-agent's own tool calls raise session-level permission requests,
1108
+ so hiding them here would let the takeover deadlock the very agent it
1109
+ is showing until the reader happened to press Back. */}
1110
+ {readOnly || subagentId !== undefined ? null : (
938
1111
  <Composer
939
1112
  ref={composerRef}
940
1113
  onSend={handleSend}
@@ -1009,6 +1182,8 @@ export function SessionPanel({
1009
1182
  </>
1010
1183
  ) : null}
1011
1184
  </div>
1185
+ </ToolResultImageProvider>
1186
+ </ToolResultFetchProvider>
1012
1187
  </TranscriptDensityProvider>
1013
1188
  </TranscriptVariantProvider>
1014
1189
  )
@@ -0,0 +1,233 @@
1
+ import {
2
+ ArrowRight,
3
+ Check,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ CircleAlert,
7
+ Dot,
8
+ PauseCircle,
9
+ } from 'lucide-react'
10
+ import { isAgentRecord, subagentLabel } from '@workerdeck/protocol'
11
+ import type { SessionInfo, SubagentInfo } from '@workerdeck/protocol'
12
+ import { Spinner } from '../ui/Spinner.tsx'
13
+ import { cn } from '../../lib/utils.ts'
14
+
15
+ /**
16
+ * The work *under* a session row, and the one row shape every client renders it
17
+ * through.
18
+ *
19
+ * This lived inside the VS Code webview first, which is exactly why the
20
+ * dashboard had none of it. Nothing in here is extension-specific: a session's
21
+ * sub-agents are a protocol fact, and a disclosure over them is a list
22
+ * affordance. It sits in `packages/ui` so the sidebar, the dashboard and (in its
23
+ * own idiom) the phone are annotating rows the same way rather than three ways.
24
+ *
25
+ * Today the only source is `SessionInfo.subagents`. The other — the CLI's own
26
+ * **task checklist**, the to-do list it keeps for the current turn — is what the
27
+ * design was drawn from, and it is not built: nothing on the wire carries it yet
28
+ * (see `_docs/features/sub-agent-handling.md`, second thread, which opens with
29
+ * "check a capture" rather than "design a surface"). When it arrives it is a
30
+ * *source*, not a second row component: checklist when the session has one, its
31
+ * sub-agents otherwise.
32
+ *
33
+ * That is also why `state` has a `pending` arm no sub-agent can produce. A
34
+ * sub-agent record exists only once dispatched, so it is never queued; a to-do
35
+ * is queued for most of its life, and dropping the state would mean widening the
36
+ * union later — the shape is cheaper to state now than to retrofit.
37
+ */
38
+ export type Step = {
39
+ key: string
40
+ label: string
41
+ /** What one of these is called, for the disclosure's count. */
42
+ noun: string
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.
48
+ *
49
+ * Two values and not a boolean because the checklist source this shape was
50
+ * drawn for (see above) produces the second kind natively — a to-do is a task
51
+ * in exactly this sense, and it will slot in here rather than widening
52
+ * anything.
53
+ */
54
+ kind: 'agent' | 'task'
55
+ state: 'done' | 'running' | 'pending' | 'failed'
56
+ /** A trailing reading — a sub-agent's tool count. Absent draws nothing. */
57
+ detail?: string
58
+ title: string
59
+ onSelect: () => void
60
+ }
61
+
62
+ export function sessionSteps(
63
+ info: SessionInfo,
64
+ onSelectSubagent: (toolUseId: string) => void,
65
+ ): Step[] {
66
+ // The label is protocol's `subagentLabel`, not a spelling of its own: the
67
+ // dashboard and the phone render the same rows from the same records, and two
68
+ // 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
+ }))
79
+ }
80
+
81
+ function stepState(status: SubagentInfo['status']): Step['state'] {
82
+ switch (status) {
83
+ case 'running':
84
+ return 'running'
85
+ case 'failed':
86
+ return 'failed'
87
+ default:
88
+ return 'done'
89
+ }
90
+ }
91
+
92
+ /** How many of these are still going — the live half of the disclosure's count. */
93
+ export function runningSteps(steps: readonly Step[]): number {
94
+ return steps.filter((s) => s.state === 'running').length
95
+ }
96
+
97
+ /**
98
+ * The disclosure, which is also the reading: `3 agents` — or `2 of 3 agents`
99
+ * while some have settled, because "how many are still going" is the live
100
+ * question and a bare total answers it wrong the moment one finishes.
101
+ *
102
+ * Sub-agents are an annotation on a working row rather than a state of their own
103
+ * (see `runningSubagents` in protocol's `session-list.ts`), so this never
104
+ * competes with the row's status glyph: that still says what the *session* is
105
+ * doing.
106
+ */
107
+ export function StepToggle({
108
+ expanded,
109
+ running,
110
+ total,
111
+ noun,
112
+ onToggle,
113
+ }: {
114
+ expanded: boolean
115
+ running: number
116
+ total: number
117
+ noun: string
118
+ onToggle: () => void
119
+ }) {
120
+ // Two spellings of one count. The **words** are the honest reading and go in
121
+ // the tooltip and to a screen reader; the line itself gets the digits, because
122
+ // this sits on the second line of a 280px row next to the folder and the age,
123
+ // and `1 of 6 agents` truncated the folder name away to say something the row
124
+ // could say in three characters.
125
+ const label = running > 0 && running < total ? `${running}/${total}` : String(total)
126
+ const words =
127
+ running > 0 && running < total
128
+ ? `${running} of ${total} ${noun}s running`
129
+ : `${total} ${noun}${total === 1 ? '' : 's'}`
130
+ const Chevron = expanded ? ChevronDown : ChevronRight
131
+ return (
132
+ <button
133
+ type='button'
134
+ aria-expanded={expanded}
135
+ aria-label={`${expanded ? 'Hide' : 'Show'} ${words}`}
136
+ title={`${expanded ? 'Hide' : 'Show'} ${words}`}
137
+ onClick={(e) => {
138
+ // The whole row is a button and this one does not mean "select" — the
139
+ // same guard the row's overflow needs, and the reason a drag-select
140
+ // inside the row does not toggle it.
141
+ e.stopPropagation()
142
+ onToggle()
143
+ }}
144
+ 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',
147
+ running > 0 ? 'text-info' : 'text-fg-4',
148
+ )}>
149
+ <Chevron className='size-3' />
150
+ <span className='tabular-nums'>{label}</span>
151
+ </button>
152
+ )
153
+ }
154
+
155
+ /**
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}.
162
+ *
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.
167
+ */
168
+ export function StepRow({ step, onSelect }: { step: Step; onSelect: () => void }) {
169
+ const agent = step.kind === 'agent'
170
+ // Body colour by *kind*, state carried by the icon — the rule the transcript's
171
+ // own `TaskRow` already follows, where the body is green and the marker holds
172
+ // the beat. Green means sub-agent across this product (it is the one hue
173
+ // nothing else on a session surface claims), so a list that spent blue on
174
+ // "running" here would be saying something different from the transcript
175
+ // about the same agent. Failure still outranks it: an alarm is not a category.
176
+ 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
+ return (
204
+ <button
205
+ type='button'
206
+ title={step.title}
207
+ onClick={(e) => {
208
+ e.stopPropagation()
209
+ onSelect()
210
+ }}
211
+ className={cn(shape, 'hover:bg-surface-hover', body)}>
212
+ {content}
213
+ </button>
214
+ )
215
+ }
216
+
217
+ function StepIcon({ state, kind }: { state: Step['state']; kind: Step['kind'] }) {
218
+ // A task that is neither running nor failed gets a neutral dot rather than a
219
+ // tick: `done` is a claim about work, and nothing here did any.
220
+ if (kind === 'task' && state !== 'running' && state !== 'failed') {
221
+ return <Dot className='size-3 shrink-0' />
222
+ }
223
+ switch (state) {
224
+ case 'running':
225
+ return <Spinner className='size-3 shrink-0' />
226
+ case 'failed':
227
+ return <CircleAlert className='size-3 shrink-0' />
228
+ case 'pending':
229
+ return <PauseCircle className='size-3 shrink-0' />
230
+ default:
231
+ return <Check className='size-3 shrink-0' />
232
+ }
233
+ }
@@ -41,6 +41,8 @@ export interface SessionWorkspaceProps {
41
41
  scrubberMarks?: SessionPanelProps['scrubberMarks']
42
42
  /** The last prompt pinned above the transcript — see `SessionPanel`. */
43
43
  stickyPrompt?: SessionPanelProps['stickyPrompt']
44
+ /** Take the panel body over with one sub-agent's work — see `SessionPanel`. */
45
+ openSubagent?: SessionPanelProps['openSubagent']
44
46
  /** Which end of the panel the status bar sits at — see `SessionPanel`. */
45
47
  statusPlacement?: SessionPanelProps['statusPlacement']
46
48
  controlsSurface?: SessionPanelProps['controlsSurface']
@@ -103,6 +105,7 @@ export function SessionWorkspace({
103
105
  scrubber,
104
106
  scrubberMarks,
105
107
  stickyPrompt,
108
+ openSubagent,
106
109
  statusPlacement,
107
110
  controlsSurface,
108
111
  unseen,
@@ -307,6 +310,7 @@ export function SessionWorkspace({
307
310
  scrubber={scrubber}
308
311
  scrubberMarks={scrubberMarks}
309
312
  stickyPrompt={stickyPrompt}
313
+ openSubagent={openSubagent}
310
314
  controlsSurface={controlsSurface}
311
315
  statusPlacement={statusPlacement}
312
316
  unseen={unseen}
@@ -8,6 +8,7 @@ import { Spinner } from '../ui/Spinner.tsx'
8
8
  import { Tip } from '../ui/Tooltip.tsx'
9
9
  import { cn } from '../../lib/utils.ts'
10
10
  import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
11
+ import { meterColorClass } from '../../lib/status.ts'
11
12
  import { STATUS_META } from './status.ts'
12
13
  import { useTranscriptVariant } from './transcript-variant.tsx'
13
14
 
@@ -63,8 +64,9 @@ function useNow(intervalMs = 30_000): number {
63
64
  return now
64
65
  }
65
66
 
66
- const utilizationColor = (pct: number) =>
67
- pct >= 95 ? 'text-danger' : pct >= 80 ? 'text-warning' : 'text-fg-3'
67
+ /** The shared ramp, aliased for the two call sites below — the thresholds live
68
+ * in `lib/status.ts` beside the severity they mirror. */
69
+ const utilizationColor = (pct: number) => meterColorClass(pct)
68
70
 
69
71
  /** The CLI reports category colors as its own theme token names ('inactive',
70
72
  * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */