@workerdeck/ui 0.17.0 → 0.19.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 (41) hide show
  1. package/build/{SessionPanel-DMPhsNlW.mjs → SessionPanel-CHktI2CF.mjs} +440 -75
  2. package/build/SessionPanel-CHktI2CF.mjs.map +1 -0
  3. package/build/{SessionPanel-CnNYEX80.d.mts → SessionPanel-CnU_IJ3-.d.mts} +24 -9
  4. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  5. package/build/format.d.mts +16 -5
  6. package/build/format.mjs +2 -3
  7. package/build/index.d.mts +237 -8
  8. package/build/index.mjs +399 -167
  9. package/build/index.mjs.map +1 -1
  10. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  11. package/build/status-BE-zg88x.mjs.map +1 -0
  12. package/build/workspace.d.mts +4 -1
  13. package/build/workspace.mjs +4 -3
  14. package/build/workspace.mjs.map +1 -1
  15. package/package.json +4 -4
  16. package/src/components/agent/ContextRing.tsx +41 -0
  17. package/src/components/agent/EngineIcon.tsx +40 -0
  18. package/src/components/agent/PermissionModeSelect.tsx +5 -1
  19. package/src/components/agent/SessionBrowser.tsx +115 -22
  20. package/src/components/agent/SessionPanel.tsx +153 -3
  21. package/src/components/agent/SessionSteps.tsx +233 -0
  22. package/src/components/agent/SessionWorkspace.tsx +4 -0
  23. package/src/components/agent/StatusBar.tsx +4 -2
  24. package/src/components/agent/SubagentStrip.tsx +134 -0
  25. package/src/components/agent/Transcript.tsx +138 -19
  26. package/src/components/agent/transcript-rows.ts +9 -1
  27. package/src/components/terminal/TerminalTranscript.tsx +77 -3
  28. package/src/components/terminal/affordances.tsx +34 -0
  29. package/src/components/terminal/blocks.ts +28 -0
  30. package/src/components/terminal/height.ts +24 -0
  31. package/src/components/terminal/items.tsx +1 -1
  32. package/src/components/terminal/scrubber.tsx +28 -3
  33. package/src/components/terminal/tool-run.ts +49 -5
  34. package/src/index.ts +19 -1
  35. package/src/lib/status.ts +16 -3
  36. package/src/styles/terminal.css +10 -0
  37. package/src/styles/theme.css +42 -0
  38. package/build/SessionPanel-DMPhsNlW.mjs.map +0 -1
  39. package/build/format-DqR56Y8l.mjs.map +0 -1
  40. package/build/status-Ydzi7n6j.mjs +0 -143
  41. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -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. */
@@ -0,0 +1,134 @@
1
+ import { ArrowLeft } from 'lucide-react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatDuration } from '../../lib/format.ts'
5
+ import { Button } from '../ui/Button.tsx'
6
+ import { Ink, Row } from '../terminal/row.tsx'
7
+ import { TerminalSurface } from '../terminal/surface.tsx'
8
+ import { taskBusy, taskFailed, taskIdentity } from '../terminal/tool-run.ts'
9
+ import type { ToolCallItem } from '../terminal/blocks.ts'
10
+ import { usePulse } from './pulse.tsx'
11
+ import { useTicker } from '../terminal/items.tsx'
12
+
13
+ /**
14
+ * The one line above a sub-agent takeover: who this is, how it is doing, and the
15
+ * way back.
16
+ *
17
+ * **It claims exactly what the `TaskRow` it was opened from claims** — the same
18
+ * `taskBusy` / `taskFailed` / tool count over the same items — and deliberately
19
+ * *not* `SubagentInfo.status`. Protocol documents those two as divergent on
20
+ * purpose (`index.ts:1476-1487`), but that divergence is transcript-versus-*list*
21
+ * and this surface **is** the transcript. The disagreement that must not exist
22
+ * here is between a header and the rows directly beneath it.
23
+ *
24
+ * The rollup is still allowed one job — naming an agent whose `Task` call is not
25
+ * in the transcript — because a label is not content, and `SessionInfo.subagents`
26
+ * keeps only eight settled records (`SUBAGENT_HISTORY`), so it can never be the
27
+ * source of what a frame *shows*.
28
+ *
29
+ * Both variants, because the panel has two and a strip that only existed in one
30
+ * would make the takeover a terminal-theme feature rather than a session feature.
31
+ */
32
+ export function SubagentStrip({
33
+ task,
34
+ items,
35
+ label,
36
+ onBack,
37
+ terminal,
38
+ fontSize,
39
+ lineHeight,
40
+ }: {
41
+ /** The spawning call. Absent when the transcript does not have it — the strip
42
+ * still draws, because the way back must exist even when the content cannot. */
43
+ task: ToolCallItem | undefined
44
+ /** The frame's items, for the busy reading and the tool count. */
45
+ items: readonly TranscriptItem[]
46
+ /** Fallback name when there is no task item. */
47
+ label: string
48
+ onBack: () => void
49
+ terminal: boolean
50
+ fontSize?: number
51
+ lineHeight?: number
52
+ }) {
53
+ const busy = task ? taskBusy(task, items) : false
54
+ const failed = task ? taskFailed(task) : false
55
+ const pulse = usePulse(busy)
56
+ const tools = items.reduce((n, item) => n + (item.kind === 'tool_call' ? 1 : 0), 0)
57
+ // Ticks only while it works, off the same once-a-second clock the working
58
+ // line uses. A settled agent has no end timestamp to measure against, and
59
+ // "N tools" is the settled reading anyway — a frozen clock would read as a
60
+ // stall. Absent `ts` (an item from before the reducer stamped it) simply
61
+ // draws no elapsed rather than counting from the epoch.
62
+ const startedAt = busy ? task?.ts : undefined
63
+ const now = useTicker(startedAt !== undefined)
64
+ const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
65
+
66
+ const name = task ? taskIdentity(task) : label
67
+ /**
68
+ * Running or finished, in the theme's own words rather than new ones:
69
+ * `taskSummary` already says `working…` and `done` for exactly this state one
70
+ * row over, and a header that said "running"/"completed" about the same agent
71
+ * would be a second vocabulary for one fact. The trailing ellipsis is the
72
+ * theme's in-flight signal; the pulse beside it is the beat.
73
+ *
74
+ * Unknown when the transcript has no `Task` call to read — better silent than
75
+ * confidently wrong about an agent we cannot see.
76
+ */
77
+ const status = !task ? undefined : failed ? 'failed' : busy ? `${pulse} working…` : 'done'
78
+ const detail = [tools > 0 ? `${tools} tool${tools === 1 ? '' : 's'}` : undefined, elapsed]
79
+ .filter(Boolean)
80
+ .join(' · ')
81
+
82
+ if (terminal) {
83
+ return (
84
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className='shrink-0'>
85
+ {/* A row on the grid, not a chrome bar: the takeover is a mode of the
86
+ transcript, and a toolbar in some other metric above it would read as
87
+ a different application's. The whole line is the target — there is
88
+ exactly one thing to do here. */}
89
+ <button
90
+ type='button'
91
+ onClick={onBack}
92
+ aria-label='Back to the session'
93
+ className='block w-full cursor-pointer text-left'>
94
+ {/* The arrow is in the gutter unconditionally. It used to give way to
95
+ the pulse while the agent worked, which put the way *out* of the
96
+ frame on a timer — the one control here should not come and go.
97
+ The beat moved into the status instead. `indent` because this is a
98
+ frame around the rows rather than one of them, and a marker flush
99
+ against the panel edge reads as a clipped row. */}
100
+ <Row glyph='←' glyphTone='dim' indent={1} tone={failed ? 'red' : 'green'}>
101
+ {name}
102
+ {status ? (
103
+ <Ink tone={failed ? 'red' : busy ? 'mark' : 'dim'}> · {status}</Ink>
104
+ ) : null}
105
+ {detail ? <Ink tone='faint'> · {detail}</Ink> : null}
106
+ </Row>
107
+ </button>
108
+ </TerminalSurface>
109
+ )
110
+ }
111
+
112
+ return (
113
+ <div className='flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5'>
114
+ <Button variant='ghost' size='sm' onClick={onBack} className='h-6 gap-1 px-1.5'>
115
+ <ArrowLeft className='size-3.5' />
116
+ Back
117
+ </Button>
118
+ <span
119
+ className={cn('min-w-0 flex-1 truncate text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>
120
+ {name}
121
+ </span>
122
+ {status ? (
123
+ <span
124
+ className={cn(
125
+ 'shrink-0 text-label',
126
+ failed ? 'text-danger' : busy ? 'text-accent' : 'text-fg-3',
127
+ )}>
128
+ {status}
129
+ </span>
130
+ ) : null}
131
+ {detail ? <span className='shrink-0 text-label text-fg-4'>{detail}</span> : null}
132
+ </div>
133
+ )
134
+ }