@workerdeck/ui 2.10.0 → 2.12.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 (37) hide show
  1. package/build/{SessionPanel-BVbau2eR.d.mts → SessionPanel-CJwp5B3q.d.mts} +8 -3
  2. package/build/index.d.mts +39 -20
  3. package/build/index.mjs +119 -52
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +60 -11
  6. package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-CepFsMdW.mjs} +541 -254
  7. package/build/use-path-links-CepFsMdW.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/Composer.tsx +28 -5
  12. package/src/components/agent/PromptTokenText.tsx +9 -1
  13. package/src/components/agent/SessionBrowser.tsx +17 -1
  14. package/src/components/agent/SessionItem.tsx +10 -26
  15. package/src/components/agent/SessionPanel.tsx +12 -0
  16. package/src/components/agent/SessionSteps.tsx +15 -55
  17. package/src/components/agent/SubagentToggle.tsx +69 -0
  18. package/src/components/agent/Transcript.tsx +91 -80
  19. package/src/components/agent/TranscriptItemView.tsx +2 -0
  20. package/src/components/agent/TranscriptRows.tsx +60 -56
  21. package/src/components/agent/UsageDialog.tsx +19 -6
  22. package/src/components/agent/composer-commands.ts +18 -0
  23. package/src/components/agent/pulse.tsx +53 -16
  24. package/src/components/agent/use-transcript-jumps.ts +25 -0
  25. package/src/components/terminal/TerminalTranscript.tsx +31 -27
  26. package/src/components/terminal/blocks.ts +15 -7
  27. package/src/components/terminal/height.ts +14 -5
  28. package/src/components/terminal/items.tsx +97 -11
  29. package/src/components/terminal/peer-names.tsx +16 -0
  30. package/src/components/terminal/row.tsx +1 -1
  31. package/src/components/terminal/tool-run.ts +68 -0
  32. package/src/index.ts +10 -2
  33. package/src/lib/session-cost.ts +12 -2
  34. package/src/lib/tool-icon.ts +5 -1
  35. package/src/styles/terminal.css +102 -1
  36. package/src/styles/theme.css +1 -1
  37. package/build/use-path-links-CvcxdUXL.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { i as SessionPanelProps } from "./SessionPanel-BVbau2eR.mjs";
1
+ import { i as SessionPanelProps } from "./SessionPanel-CJwp5B3q.mjs";
2
2
  import { CSSProperties } from "react";
3
3
  import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
4
4
  import { WorkerDeckClient } from "@workerdeck/client";
@@ -1,4 +1,4 @@
1
- import { At as Spinner, B as EntryIcon, Ft as TooltipProvider, Nt as Tip, Ot as Splitter, an as Button, i as SessionPanel, n as usePathLinks, nn as Input, sn as cn, t as resolveAgainstCwd } from "./use-path-links-CvcxdUXL.mjs";
1
+ import { B as EntryIcon, It as Tip, Nt as Spinner, Rt as TooltipProvider, an as Input, cn as Button, i as SessionPanel, jt as Splitter, n as usePathLinks, t as resolveAgainstCwd, un as cn } from "./use-path-links-CepFsMdW.mjs";
2
2
  import { m as formatBytes } from "./status-kmsBhuFG.mjs";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "type": "module",
5
5
  "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  "streamdown": "^2.6.0",
40
40
  "tailwind-merge": "^3.7.0",
41
41
  "use-stick-to-bottom": "^1.1.6",
42
- "@workerdeck/client": "2.10.0",
43
- "@workerdeck/react": "2.10.0",
44
- "@workerdeck/protocol": "2.10.0"
42
+ "@workerdeck/protocol": "2.12.0",
43
+ "@workerdeck/client": "2.12.0",
44
+ "@workerdeck/react": "2.12.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -10,15 +10,15 @@ import {
10
10
  type Ref,
11
11
  } from 'react'
12
12
  import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
13
- import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
14
- import { ArrowUp, FileText, Paperclip, RotateCw, SlidersHorizontal, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
13
+ import type { PeerSessionOption, StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
14
+ import { ArrowUp, FileText, Paperclip, RotateCw, SlidersHorizontal, Sparkles, Square, TriangleAlert, Users, X } from 'lucide-react'
15
15
  import { Button } from '../ui/Button.tsx'
16
16
  import { Spinner } from '../ui/Spinner.tsx'
17
17
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
18
18
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
19
19
  import { plainTextToSegments } from '../prompt-area/prompt-area-engine.ts'
20
- import { commandTrigger, launchTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
21
- import { mergeComposerRows, rankComposerRows, skillTrailingText, type ClientCommand } from './composer-commands.ts'
20
+ import { commandTrigger, hashtagTrigger, launchTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
21
+ import { mergeComposerRows, rankComposerRows, rankPeerSessions, skillTrailingText, type ClientCommand } from './composer-commands.ts'
22
22
  import { useTranscriptVariant } from './transcript-variant.tsx'
23
23
  import type { TerminalAffordances } from '../terminal/affordances.tsx'
24
24
  import { PROMPT_GLYPH } from '../terminal/items.tsx'
@@ -49,6 +49,9 @@ export interface ComposerProps {
49
49
  // commands, and suppressed name-for-name by an engine command so the real one always wins.
50
50
  clientCommands?: ClientCommand[]
51
51
  onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
52
+ // The other sessions on this gateway, offered on `#`. The token is a hint the gateway resolves at
53
+ // send: it never messages the named session, and an unresolvable name stays ordinary text.
54
+ peers?: readonly PeerSessionOption[]
52
55
  // Shell mode: `!` as the first character turns the composer into a host shell prompt. Omit to leave the
53
56
  // mode off entirely - the gateway only offers it to an operator on a session whose engine reaches a host cwd.
54
57
  onShellCommand?: (command: string) => void
@@ -75,6 +78,7 @@ export function Composer({
75
78
  skills,
76
79
  clientCommands,
77
80
  onSearchFiles,
81
+ peers,
78
82
  onShellCommand,
79
83
  attachments,
80
84
  toolbar,
@@ -172,6 +176,24 @@ export function Composer({
172
176
  }),
173
177
  )
174
178
  }
179
+ if (peers?.length) {
180
+ configured.push(
181
+ hashtagTrigger({
182
+ accessibilityLabel: 'session',
183
+ onSearch: (query: string): TriggerSuggestion[] =>
184
+ rankPeerSessions(query, peers).map((peer) => ({
185
+ value: peer.slug,
186
+ label: peer.label,
187
+ description: [peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
188
+ icon: <Users className="size-3.5 text-fg-3" />,
189
+ })),
190
+ onSelect: (suggestion) => suggestion.value,
191
+ chipStyle: 'inline',
192
+ chipClassName: 'font-mono',
193
+ emptyMessage: 'No matching sessions',
194
+ }),
195
+ )
196
+ }
175
197
  if (onShellCommand) {
176
198
  configured.push(
177
199
  launchTrigger({
@@ -182,7 +204,7 @@ export function Composer({
182
204
  )
183
205
  }
184
206
  return configured.length > 0 ? configured : undefined
185
- }, [commands, skills, clientCommands, onSearchFiles, onShellCommand])
207
+ }, [commands, skills, clientCommands, onSearchFiles, peers, onShellCommand])
186
208
 
187
209
  const saveDraft = draft?.save
188
210
  useEffect(() => {
@@ -223,6 +245,7 @@ export function Composer({
223
245
  const hints = [
224
246
  triggers?.some((t) => t.char === '/') ? { key: '/', what: 'commands and skills' } : undefined,
225
247
  onSearchFiles ? { key: '@', what: 'mention a file' } : undefined,
248
+ peers?.length ? { key: '#', what: 'refer to another session' } : undefined,
226
249
  onShellCommand ? { key: '!', what: 'run a shell command' } : undefined,
227
250
  { key: '?', what: 'this list, on an empty composer' },
228
251
  ].filter((h) => h !== undefined)
@@ -4,14 +4,22 @@ import { cn } from '../../lib/utils.ts'
4
4
 
5
5
  const SkillNamesContext = createContext<readonly string[] | undefined>(undefined)
6
6
 
7
+ const SessionNamesContext = createContext<readonly string[] | undefined>(undefined)
8
+
7
9
  // The skill names a `$name` in a sent message may resolve to; unset, nothing is a skill token.
8
10
  export function SkillNamesProvider({ names, children }: { names: readonly string[] | undefined; children: ReactNode }) {
9
11
  return <SkillNamesContext.Provider value={names}>{children}</SkillNamesContext.Provider>
10
12
  }
11
13
 
14
+ // The folded peer slugs a `#Name` in a sent message may resolve to; unset, nothing is a session token.
15
+ export function SessionNamesProvider({ names, children }: { names: readonly string[] | undefined; children: ReactNode }) {
16
+ return <SessionNamesContext.Provider value={names}>{children}</SessionNamesContext.Provider>
17
+ }
18
+
12
19
  export function PromptTokenText({ text, className }: { text: string; className?: string }) {
13
20
  const skills = useContext(SkillNamesContext)
14
- const tokens = scanPromptTokens(text, { skills })
21
+ const sessions = useContext(SessionNamesContext)
22
+ const tokens = scanPromptTokens(text, { skills, sessions })
15
23
  if (tokens.length === 0) {
16
24
  return <span className={className}>{text}</span>
17
25
  }
@@ -12,7 +12,7 @@ import {
12
12
  projectsOf,
13
13
  subsetSummary,
14
14
  } from '@workerdeck/protocol'
15
- import type { GroupBy, SessionRow, SessionState, SortBy, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
15
+ import type { GroupBy, SessionRow, SessionState, SortBy, SubagentDisplay, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
16
16
  import { Button } from '../ui/Button.tsx'
17
17
  import { Empty } from '../ui/Empty.tsx'
18
18
  import { Input } from '../ui/Input.tsx'
@@ -142,6 +142,18 @@ export function SessionBrowser({
142
142
  onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
143
143
  />
144
144
  </FilterRow>
145
+ <FilterRow label="Sub-agents">
146
+ <OneOfSelect
147
+ label="Sub-agents"
148
+ value={config.subagents}
149
+ options={[
150
+ { value: 'active', label: 'Hide completed' },
151
+ { value: 'all', label: 'Show all' },
152
+ { value: 'none', label: 'Hide all' },
153
+ ]}
154
+ onChange={(subagents) => set({ subagents: subagents as SubagentDisplay })}
155
+ />
156
+ </FilterRow>
145
157
  <FilterRow label="Sort">
146
158
  <OneOfSelect
147
159
  label="Sort"
@@ -209,6 +221,7 @@ export function SessionBrowser({
209
221
  activeSubagentId={activeSubagentId}
210
222
  showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
211
223
  showProject={config.groupBy !== 'project'}
224
+ subagents={config.subagents}
212
225
  projectIcons={projectIcons}
213
226
  onSelect={onSelect}
214
227
  onDelete={onDelete}
@@ -236,6 +249,7 @@ interface SessionRowItemProps {
236
249
  activeSubagentId?: string
237
250
  showGateway?: boolean
238
251
  showProject?: boolean
252
+ subagents?: SubagentDisplay
239
253
  projectIcons?: Record<string, string>
240
254
  onSelect?: (row: SessionRow) => void
241
255
  onDelete?: (row: SessionRow) => void
@@ -250,6 +264,7 @@ function SessionRowItem({
250
264
  activeSubagentId,
251
265
  showGateway,
252
266
  showProject = true,
267
+ subagents,
253
268
  projectIcons,
254
269
  onSelect,
255
270
  onDelete,
@@ -267,6 +282,7 @@ function SessionRowItem({
267
282
  activeStepKey={activeSubagentId}
268
283
  showGateway={showGateway}
269
284
  showProject={showProject}
285
+ subagents={subagents}
270
286
  projectIcons={projectIcons}
271
287
  onSelect={() => onSelect?.(row)}
272
288
  onSelectSubagent={onSelectSubagent ? (id) => onSelectSubagent(row, id) : undefined}
@@ -1,12 +1,12 @@
1
1
  import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  import type { ReactNode } from 'react'
3
- import { projectLabel, projectSubpath, sessionLabel } from '@workerdeck/protocol'
4
- import type { SessionRow } from '@workerdeck/protocol'
3
+ import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
4
+ import type { SessionRow, SubagentDisplay } from '@workerdeck/protocol'
5
5
  import { ContextRing } from './ContextRing.tsx'
6
6
  import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
7
7
  import { ProjectIcon } from './ProjectIcon.tsx'
8
8
  import { SessionStatusIcon } from './SessionStatusIcon.tsx'
9
- import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
9
+ import { StepRow, sessionSteps } from './SessionSteps.tsx'
10
10
  import type { Step } from './SessionSteps.tsx'
11
11
  import { cn } from '../../lib/utils.ts'
12
12
  import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
@@ -20,8 +20,7 @@ export interface SessionItemProps {
20
20
  showGateway?: boolean
21
21
  showProject?: boolean
22
22
  projectIcons?: Record<string, string>
23
- expanded?: boolean
24
- onExpandedChange?: (expanded: boolean) => void
23
+ subagents?: SubagentDisplay
25
24
  onSelect?: (modifiers: SelectModifiers) => void
26
25
  onSelectSubagent?: (toolUseId: string) => void
27
26
  onRename?: (title: string) => void
@@ -41,8 +40,7 @@ export function SessionItem({
41
40
  showGateway,
42
41
  showProject = true,
43
42
  projectIcons,
44
- expanded,
45
- onExpandedChange,
43
+ subagents = 'active',
46
44
  onSelect,
47
45
  onSelectSubagent,
48
46
  onRename,
@@ -53,12 +51,6 @@ export function SessionItem({
53
51
  className,
54
52
  }: SessionItemProps) {
55
53
  const { info } = row
56
- const [ownExpanded, setOwnExpanded] = useState(false)
57
- const open = expanded ?? ownExpanded
58
- const setOpen = (next: boolean) => {
59
- setOwnExpanded(next)
60
- onExpandedChange?.(next)
61
- }
62
54
  const [ownEditing, setOwnEditing] = useState(false)
63
55
  const isEditing = editing ?? ownEditing
64
56
  const setEditing = (next: boolean) => {
@@ -68,6 +60,7 @@ export function SessionItem({
68
60
 
69
61
  const engine = info.engine ?? 'claude'
70
62
  const project = showProject ? projectLabel(row) : projectSubpath(row)
63
+ const projectTitle = showProject ? projectName(row) : undefined
71
64
  const projectIcon = showProject ? info.project?.icon : undefined
72
65
  const iconSrc = projectIcon?.type === 'image' ? projectIcons?.[projectIcon.hash] : undefined
73
66
  const cost = formatCost(info.costUsd ?? info.totalCostUsd)
@@ -88,8 +81,8 @@ export function SessionItem({
88
81
  }
89
82
  if (project !== undefined) {
90
83
  parts.push(
91
- <span key="project">
92
- <ProjectIcon icon={projectIcon} src={iconSrc} name={project} className="mr-1.5 size-4 align-[-0.3em]" />
84
+ <span key="project" title={projectTitle}>
85
+ <ProjectIcon icon={projectIcon} src={iconSrc} name={projectTitle} className="mr-1.5 size-4 align-[-0.3em]" />
93
86
  {project}
94
87
  </span>,
95
88
  )
@@ -97,7 +90,7 @@ export function SessionItem({
97
90
  for (const extra of extras) {
98
91
  parts.push(<span key={extra}>{extra}</span>)
99
92
  }
100
- const steps = sessionSteps(info, (toolUseId) => (onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.(NO_MODIFIERS)))
93
+ const steps = sessionSteps(info, (toolUseId) => (onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.(NO_MODIFIERS)), subagents)
101
94
  const holdsOpenAgent = steps.some((s) => s.key === activeStepKey)
102
95
 
103
96
  return (
@@ -194,20 +187,11 @@ export function SessionItem({
194
187
  {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
195
188
  </span>
196
189
  <span className="min-w-0 flex-1" />
197
- {steps.length > 0 ? (
198
- <StepToggle
199
- expanded={open}
200
- running={runningSteps(steps)}
201
- total={steps.length}
202
- noun={steps[0]!.noun}
203
- onToggle={() => setOpen(!open)}
204
- />
205
- ) : null}
206
190
  {actions}
207
191
  </div>
208
192
  </div>
209
193
 
210
- {open && steps.length > 0 ? (
194
+ {steps.length > 0 ? (
211
195
  <div className="flex flex-col">
212
196
  {steps.map((step: Step) => (
213
197
  <StepRow key={step.key} step={step} active={step.key === activeStepKey} onSelect={step.onSelect} />
@@ -28,6 +28,7 @@ import {
28
28
  useClaudeSession,
29
29
  useDraft,
30
30
  useHostFileSearch,
31
+ usePeerSessions,
31
32
  useProfileUsage,
32
33
  useToolCallHost,
33
34
  type ClientToolHandler,
@@ -133,6 +134,9 @@ export interface SessionPanelProps {
133
134
  scrubber?: boolean
134
135
  // Bookmarked transcript item ids - the host owns membership and persistence.
135
136
  bookmarks?: readonly string[]
137
+ // `#` mentions of the other sessions on this gateway. On by default; an embedder whose people
138
+ // should not learn that the other sessions exist turns it off.
139
+ peerMentions?: boolean
136
140
  onToggleBookmark?: (itemId: string) => void
137
141
  // Where a file link in the transcript goes. Without it, such a link stays an ordinary anchor.
138
142
  onOpenFile?: FileLinkOpener
@@ -226,6 +230,7 @@ export function SessionPanel({
226
230
  terminalMetrics,
227
231
  scrubber = false,
228
232
  bookmarks,
233
+ peerMentions = true,
229
234
  onToggleBookmark,
230
235
  reveal,
231
236
  subagents,
@@ -264,6 +269,7 @@ export function SessionPanel({
264
269
  replaying,
265
270
  protocolMismatch,
266
271
  shell,
272
+ pricing,
267
273
  models,
268
274
  effectiveModel,
269
275
  handle,
@@ -498,6 +504,9 @@ export function SessionPanel({
498
504
  engine: state.engine,
499
505
  })
500
506
  const hostFiles = useHostFileSearch(client, state.cwd)
507
+ // Read here rather than taken as a prop, the way file search is: every embedding that has a
508
+ // gateway has the sessions list, and a host that has to wire it would be a host that forgets to.
509
+ const peers = usePeerSessions(client, sessionId, peerMentions)
501
510
  const draft = useDraft(client, sessionId)
502
511
  // Stable identity: an inline arrow here would bust the Composer's `triggers`
503
512
  // memo on every streaming re-render, and with it every prompt-area callback
@@ -698,6 +707,7 @@ export function SessionPanel({
698
707
  fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
699
708
  attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
700
709
  canBrowseFiles={hostFiles.available}
710
+ sessionNames={peers.names}
701
711
  hostImage={hostImage}
702
712
  variant={transcriptVariant}
703
713
  density={transcriptDensity}
@@ -802,6 +812,7 @@ export function SessionPanel({
802
812
  attachments={attachments}
803
813
  draft={draft}
804
814
  onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
815
+ peers={peers.peers}
805
816
  onShellCommand={shell ? runShell : undefined}
806
817
  layout={controlsExternal ? 'inline' : 'stacked'}
807
818
  toolbar={controlsExternal ? undefined : sessionControls}
@@ -835,6 +846,7 @@ export function SessionPanel({
835
846
  totalCostUsd={state.totalCostUsd}
836
847
  costUsd={state.costUsd}
837
848
  usageByModel={state.usageByModel}
849
+ pricing={pricing}
838
850
  spend={profileSpend}
839
851
  updatedAt={usageUpdatedAt}
840
852
  open={panel === 'usage'}
@@ -1,6 +1,6 @@
1
- import { ArrowRight, Check, ChevronDown, ChevronRight, CircleAlert } from 'lucide-react'
2
- import { isAgentRecord, subagentLabel } from '@workerdeck/protocol'
3
- import type { SessionInfo, SubagentInfo } from '@workerdeck/protocol'
1
+ import { ArrowRight, Check, CircleAlert } from 'lucide-react'
2
+ import { isAgentRecord, subagentLabel, visibleSubagents } from '@workerdeck/protocol'
3
+ import type { SessionInfo, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
4
4
  import { Spinner } from '../ui/Spinner.tsx'
5
5
  import { cn } from '../../lib/utils.ts'
6
6
 
@@ -14,16 +14,18 @@ export type Step = {
14
14
  onSelect: () => void
15
15
  }
16
16
 
17
- export function sessionSteps(info: SessionInfo, onSelect: (toolUseId: string) => void): Step[] {
18
- return (info.subagents ?? []).filter(isAgentRecord).map((sub) => ({
19
- key: sub.toolUseId,
20
- label: subagentLabel(sub),
21
- noun: 'agent',
22
- state: stepState(sub.status),
23
- detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
24
- title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
25
- onSelect: () => onSelect(sub.toolUseId),
26
- }))
17
+ export function sessionSteps(info: SessionInfo, onSelect: (toolUseId: string) => void, show: SubagentDisplay = 'all'): Step[] {
18
+ return visibleSubagents(info, show)
19
+ .filter(isAgentRecord)
20
+ .map((sub) => ({
21
+ key: sub.toolUseId,
22
+ label: subagentLabel(sub),
23
+ noun: 'agent',
24
+ state: stepState(sub.status),
25
+ detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
26
+ title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
27
+ onSelect: () => onSelect(sub.toolUseId),
28
+ }))
27
29
  }
28
30
 
29
31
  function stepState(status: SubagentInfo['status']): Step['state'] {
@@ -40,48 +42,6 @@ function stepState(status: SubagentInfo['status']): Step['state'] {
40
42
  }
41
43
  }
42
44
 
43
- export function runningSteps(steps: readonly Step[]): number {
44
- return steps.filter((s) => s.state === 'running').length
45
- }
46
-
47
- export function StepToggle({
48
- expanded,
49
- running,
50
- total,
51
- noun,
52
- onToggle,
53
- }: {
54
- expanded: boolean
55
- running: number
56
- total: number
57
- noun: string
58
- onToggle: () => void
59
- }) {
60
- const label = running > 0 && running < total ? `${running}/${total}` : String(total)
61
- const words = running > 0 && running < total ? `${running} of ${total} ${noun}s running` : `${total} ${noun}${total === 1 ? '' : 's'}`
62
- const Chevron = expanded ? ChevronDown : ChevronRight
63
- return (
64
- <button
65
- type="button"
66
- aria-expanded={expanded}
67
- aria-label={`${expanded ? 'Hide' : 'Show'} ${words}`}
68
- title={`${expanded ? 'Hide' : 'Show'} ${words}`}
69
- onClick={(e) => {
70
- e.stopPropagation()
71
- onToggle()
72
- }}
73
- className={cn(
74
- 'flex shrink-0 items-center gap-0.5 rounded-[4px] py-0.5 pr-1 pl-0.5 outline-none',
75
- 'text-[0.75rem] leading-3 hover:bg-row-hover hover:text-fg-2',
76
- running > 0 ? 'text-info' : 'text-fg-4',
77
- )}
78
- >
79
- <Chevron className="size-3" />
80
- <span className="tabular-nums">{label}</span>
81
- </button>
82
- )
83
- }
84
-
85
45
  export function StepRow({ step, active = false, onSelect }: { step: Step; active?: boolean; onSelect: () => void }) {
86
46
  const body = step.state === 'failed' ? 'text-danger' : 'text-success'
87
47
  return (
@@ -0,0 +1,69 @@
1
+ import { Check, ChevronDown } from 'lucide-react'
2
+ import type { SubagentDisplay } from '@workerdeck/protocol'
3
+ import { Button } from '../ui/Button.tsx'
4
+ import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
5
+ import { cn } from '../../lib/utils.ts'
6
+
7
+ export const SUBAGENT_DISPLAY_ORDER: readonly SubagentDisplay[] = ['all', 'active', 'none']
8
+
9
+ export const SUBAGENT_DISPLAY_META: Record<SubagentDisplay, { label: string; hint: string }> = {
10
+ all: { label: 'All sub-agents', hint: 'Showing every sub-agent' },
11
+ active: { label: 'Hide completed', hint: 'Showing running and failed sub-agents' },
12
+ none: { label: 'Hide sub-agents', hint: 'Sub-agents hidden' },
13
+ }
14
+
15
+ const HIDDEN_ROW = 0.34
16
+
17
+ // A session line at full strength over two child rows, and a hidden child is greyed rather than
18
+ // struck: a sub-agent the card is not drawing has not failed, and a cross would say it had.
19
+ export function SubagentDisplayIcon({ value, className }: { value: SubagentDisplay; className?: string }) {
20
+ return (
21
+ <svg
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ strokeWidth={2}
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ aria-hidden="true"
29
+ className={className}
30
+ >
31
+ <path d="M3 5h18" />
32
+ <path d="M9 12h12" opacity={value === 'none' ? HIDDEN_ROW : 1} />
33
+ <path d="M9 19h12" opacity={value === 'all' ? 1 : HIDDEN_ROW} />
34
+ </svg>
35
+ )
36
+ }
37
+
38
+ export type SubagentToggleProps = {
39
+ value: SubagentDisplay
40
+ onChange: (value: SubagentDisplay) => void
41
+ className?: string
42
+ }
43
+
44
+ // A menu rather than a press-to-cycle button: three stops are one more than a person can read off
45
+ // a single glyph, and the caret is what promises the other two.
46
+ export function SubagentToggle({ value, onChange, className }: SubagentToggleProps) {
47
+ const meta = SUBAGENT_DISPLAY_META[value]
48
+ return (
49
+ <Menu>
50
+ <MenuTrigger
51
+ render={
52
+ <Button variant="ghost" size="icon-sm" aria-label={`Sub-agents: ${meta.label}`} title={meta.hint} className={cn('w-9 gap-0.5', className)}>
53
+ <SubagentDisplayIcon value={value} className="size-4" />
54
+ <ChevronDown className="size-2.5 text-fg-4" />
55
+ </Button>
56
+ }
57
+ />
58
+ <MenuContent className="min-w-44">
59
+ {SUBAGENT_DISPLAY_ORDER.map((option) => (
60
+ <MenuItem key={option} onClick={() => onChange(option)}>
61
+ <SubagentDisplayIcon value={option} className="size-4 shrink-0 text-fg-3" />
62
+ <span className="flex-1">{SUBAGENT_DISPLAY_META[option].label}</span>
63
+ {option === value ? <Check className="size-3.5 shrink-0 text-accent" /> : null}
64
+ </MenuItem>
65
+ ))}
66
+ </MenuContent>
67
+ </Menu>
68
+ )
69
+ }