@workerdeck/ui 2.15.0 → 3.1.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 (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -1,28 +1,52 @@
1
- import { ArrowRight, Check, CircleAlert } from 'lucide-react'
2
- import { isAgentRecord, promotedShells, subagentLabel, visibleSubagents } from '@workerdeck/protocol'
3
- import type { SessionInfo, ShellInfo, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
1
+ import { ArrowRight, Check, Circle, CircleAlert } from 'lucide-react'
2
+ import { displayedTasks, isAgentRecord, subagentLabel, visibleShells, visibleSubagents } from '@workerdeck/protocol'
3
+ import type { SessionInfo, SessionTask, ShellInfo, StepDisplay, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
4
4
  import { Spinner } from '../ui/Spinner.tsx'
5
5
  import { cn } from '../../lib/utils.ts'
6
- import { SHELL_GLYPH, SHELL_KILL_GLYPH, shellInfoFailed, shellInfoLabel, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
6
+ import { AgentWriteIcon } from '../terminal/affordances.tsx'
7
+ import {
8
+ SHELL_AGENT_WRITE_NOTE,
9
+ SHELL_GLYPH,
10
+ SHELL_KILL_GLYPH,
11
+ shellAgentWriteLabel,
12
+ shellGrantable,
13
+ shellInfoFailed,
14
+ shellInfoLabel,
15
+ shellInfoStatusText,
16
+ shellTitle,
17
+ } from '../terminal/shell-row.ts'
7
18
 
8
- export type StepKind = 'agent' | 'shell'
19
+ export type StepKind = 'agent' | 'shell' | 'task'
9
20
 
10
21
  export type Step = {
11
22
  key: string
12
23
  kind: StepKind
13
24
  label: string
14
25
  noun: string
15
- state: 'done' | 'running' | 'failed'
26
+ state: 'done' | 'running' | 'failed' | 'pending'
16
27
  detail?: string
17
28
  title: string
18
29
  onSelect: () => void
19
30
  onKill?: () => void
31
+ killLabel?: string
32
+ agentWrite?: { granted: boolean; label: string; toggle: () => void }
20
33
  }
21
34
 
22
35
  export type ShellStepOptions = {
23
36
  now: number
37
+ show?: StepDisplay
24
38
  onSelect: (shellId: string) => void
25
39
  onKill?: (shellId: string) => void
40
+ // Offered only where the session's agent holds the shell write tools.
41
+ onAgentWrite?: (shellId: string, enabled: boolean) => void
42
+ }
43
+
44
+ export type TaskStepOptions = {
45
+ show: StepDisplay
46
+ // Whether the session is still working: a checklist item left in progress after the turn ended is not running.
47
+ live: boolean
48
+ onSelect: (task: SessionTask) => void
49
+ onStop?: (toolUseId: string) => void
26
50
  }
27
51
 
28
52
  export function sessionSteps(
@@ -30,6 +54,7 @@ export function sessionSteps(
30
54
  onSelect: (toolUseId: string) => void,
31
55
  show: SubagentDisplay = 'all',
32
56
  shells?: ShellStepOptions,
57
+ tasks?: TaskStepOptions,
33
58
  ): Step[] {
34
59
  const agents: Step[] = visibleSubagents(info, show)
35
60
  .filter(isAgentRecord)
@@ -43,13 +68,33 @@ export function sessionSteps(
43
68
  title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
44
69
  onSelect: () => onSelect(sub.toolUseId),
45
70
  }))
71
+ const taskSteps = tasks === undefined ? [] : displayedTasks(info, tasks.show).map((task) => taskStep(task, tasks))
46
72
  if (shells === undefined) {
47
- return agents
73
+ return [...agents, ...taskSteps]
74
+ }
75
+ const grants = info.shellAgentWrite !== undefined
76
+ const shellSteps = visibleShells(info, shells.show ?? 'active', shells.now).map((shell) => shellStep(shell, shells, grants))
77
+ return [...agents, ...taskSteps, ...shellSteps]
78
+ }
79
+
80
+ function taskStep(task: SessionTask, options: TaskStepOptions): Step {
81
+ const stalled = task.source === 'checklist' && task.state === 'running' && !options.live
82
+ const toolUseId = task.toolUseId
83
+ return {
84
+ key: task.key,
85
+ kind: 'task',
86
+ label: task.label,
87
+ noun: 'task',
88
+ state: stalled ? 'pending' : task.state,
89
+ detail: task.detail,
90
+ title: stalled ? `${task.label} · left in progress` : task.label,
91
+ onSelect: () => options.onSelect(task),
92
+ onKill: task.stoppable && toolUseId !== undefined && options.onStop ? () => options.onStop?.(toolUseId) : undefined,
93
+ killLabel: 'Stop this task',
48
94
  }
49
- return [...agents, ...promotedShells(info, shells.now).map((shell) => shellStep(shell, shells))]
50
95
  }
51
96
 
52
- function shellStep(shell: ShellInfo, options: ShellStepOptions): Step {
97
+ function shellStep(shell: ShellInfo, options: ShellStepOptions, grants: boolean): Step {
53
98
  const status = shellInfoStatusText(shell)
54
99
  const running = shell.status === 'running'
55
100
  return {
@@ -58,10 +103,19 @@ function shellStep(shell: ShellInfo, options: ShellStepOptions): Step {
58
103
  label: shellInfoLabel(shell),
59
104
  noun: 'shell',
60
105
  state: shellInfoFailed(shell) ? 'failed' : running ? 'running' : 'done',
61
- detail: running ? undefined : status,
106
+ detail: running ? (shell.agentWrite === true ? SHELL_AGENT_WRITE_NOTE : undefined) : status,
62
107
  title: `${shellTitle(shell)} · ${status}`,
63
108
  onSelect: () => options.onSelect(shell.id),
64
109
  onKill: running && options.onKill ? () => options.onKill?.(shell.id) : undefined,
110
+ killLabel: 'Kill this shell',
111
+ agentWrite:
112
+ grants && options.onAgentWrite && shellGrantable(shell)
113
+ ? {
114
+ granted: shell.agentWrite === true,
115
+ label: shellAgentWriteLabel(shell),
116
+ toggle: () => options.onAgentWrite?.(shell.id, shell.agentWrite !== true),
117
+ }
118
+ : undefined,
65
119
  }
66
120
  }
67
121
 
@@ -80,7 +134,16 @@ function stepState(status: SubagentInfo['status']): Step['state'] {
80
134
  }
81
135
 
82
136
  export function StepRow({ step, active = false, onSelect }: { step: Step; active?: boolean; onSelect: () => void }) {
83
- const body = step.state === 'failed' ? 'text-danger' : step.kind === 'shell' ? 'text-[var(--wd-shell-accent)]' : 'text-success'
137
+ const body =
138
+ step.state === 'failed'
139
+ ? 'text-danger'
140
+ : step.kind === 'shell'
141
+ ? 'text-[var(--wd-shell-accent)]'
142
+ : step.kind === 'task'
143
+ ? step.state === 'pending'
144
+ ? 'text-fg-4'
145
+ : 'text-fg-2'
146
+ : 'text-success'
84
147
  return (
85
148
  <div className={cn('flex w-full items-center rounded-[4px] pr-1.5', active ? 'bg-row-selected' : 'hover:bg-row-active', body)}>
86
149
  <button
@@ -96,13 +159,31 @@ export function StepRow({ step, active = false, onSelect }: { step: Step; active
96
159
  <StepIcon step={step} />
97
160
  <span className="min-w-0 flex-1 truncate">{step.label}</span>
98
161
  {step.detail ? <span className="shrink-0 tabular-nums text-fg-4">{step.detail}</span> : null}
99
- <ArrowRight className="size-3.5 shrink-0 text-fg-4" />
162
+ {step.kind === 'agent' ? <ArrowRight className="size-3.5 shrink-0 text-fg-4" /> : null}
100
163
  </button>
164
+ {step.agentWrite ? (
165
+ <button
166
+ type="button"
167
+ aria-label={step.agentWrite.label}
168
+ aria-pressed={step.agentWrite.granted}
169
+ title={step.agentWrite.label}
170
+ onClick={(e) => {
171
+ e.stopPropagation()
172
+ step.agentWrite?.toggle()
173
+ }}
174
+ className={cn(
175
+ 'shrink-0 px-1 text-micro leading-none outline-none',
176
+ step.agentWrite.granted ? 'text-warning' : 'text-fg-4 hover:text-fg-1',
177
+ )}
178
+ >
179
+ <AgentWriteIcon granted={step.agentWrite.granted} className="size-3.5" />
180
+ </button>
181
+ ) : null}
101
182
  {step.onKill ? (
102
183
  <button
103
184
  type="button"
104
- aria-label={`Kill ${step.label}`}
105
- title="Kill this shell"
185
+ aria-label={`${step.kind === 'task' ? 'Stop' : 'Kill'} ${step.label}`}
186
+ title={step.killLabel}
106
187
  onClick={(e) => {
107
188
  e.stopPropagation()
108
189
  step.onKill?.()
@@ -127,6 +208,9 @@ function StepIcon({ step }: { step: Step }) {
127
208
  case 'failed': {
128
209
  return <CircleAlert className="size-[11px] shrink-0" />
129
210
  }
211
+ case 'pending': {
212
+ return <Circle className="size-[11px] shrink-0" />
213
+ }
130
214
  default: {
131
215
  return <Check className="size-[11px] shrink-0" />
132
216
  }
@@ -17,8 +17,8 @@ export interface SessionWorkspaceProps {
17
17
  sessionId: string | undefined
18
18
  header?: SessionPanelProps['header']
19
19
  transcriptVariant?: SessionPanelProps['transcriptVariant']
20
- transcriptDensity?: SessionPanelProps['transcriptDensity']
21
20
  transcriptFont?: SessionPanelProps['transcriptFont']
21
+ affordances?: SessionPanelProps['affordances']
22
22
  scrubber?: SessionPanelProps['scrubber']
23
23
  bookmarks?: SessionPanelProps['bookmarks']
24
24
  onToggleBookmark?: SessionPanelProps['onToggleBookmark']
@@ -54,8 +54,8 @@ export function SessionWorkspace({
54
54
  sessionId,
55
55
  header,
56
56
  transcriptVariant,
57
- transcriptDensity,
58
57
  transcriptFont,
58
+ affordances,
59
59
  scrubber,
60
60
  bookmarks,
61
61
  onToggleBookmark,
@@ -217,8 +217,8 @@ export function SessionWorkspace({
217
217
  sessionId={sessionId}
218
218
  header={hoisted}
219
219
  transcriptVariant={transcriptVariant}
220
- transcriptDensity={transcriptDensity}
221
220
  transcriptFont={transcriptFont}
221
+ affordances={affordances}
222
222
  scrubber={scrubber}
223
223
  bookmarks={bookmarks}
224
224
  onToggleBookmark={onToggleBookmark}
@@ -1,11 +1,9 @@
1
- import { ArrowLeft, X } from 'lucide-react'
1
+ import { X } from 'lucide-react'
2
2
  import type { ShellInfo } from '@workerdeck/protocol'
3
3
  import { cn } from '../../lib/utils.ts'
4
- import { Button } from '../ui/Button.tsx'
5
- import { Ink, Row } from '../terminal/row.tsx'
6
- import { TerminalSurface } from '../terminal/surface.tsx'
7
- import { shellInfoFailed, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
8
- import { KillShellAction, WithActions } from '../terminal/affordances.tsx'
4
+ import { shellAgentWriteLabel, shellGrantable, shellInfoFailed, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
5
+ import { AgentWriteAction, AgentWriteIcon, KillShellAction } from '../terminal/affordances.tsx'
6
+ import { FrameStrip } from './FrameStrip.tsx'
9
7
 
10
8
  export interface ShellStripProps {
11
9
  shell: ShellInfo | undefined
@@ -13,48 +11,41 @@ export interface ShellStripProps {
13
11
  cols?: number
14
12
  onBack: () => void
15
13
  onKill?: () => void
14
+ onAgentWrite?: (enabled: boolean) => void
16
15
  terminal: boolean
17
16
  fontSize?: number
18
17
  lineHeight?: number
19
18
  }
20
19
 
21
- export function ShellStrip({ shell, label, cols, onBack, onKill, terminal, fontSize, lineHeight }: ShellStripProps) {
20
+ export function ShellStrip({ shell, label, cols, onBack, onKill, onAgentWrite, terminal, fontSize, lineHeight }: ShellStripProps) {
22
21
  const running = shell?.status === 'running'
23
22
  const failed = shell ? shellInfoFailed(shell) : false
24
23
  const name = shell ? shellTitle(shell) : label
25
24
  const status = shell ? shellInfoStatusText(shell) : undefined
26
25
  const width = cols ?? shell?.cols
27
26
  const detail = width === undefined ? undefined : `${width} cols`
27
+ const granted = shell?.agentWrite === true
28
+ const grant = shell && onAgentWrite && shellGrantable(shell) ? () => onAgentWrite(!granted) : undefined
28
29
 
29
- if (terminal) {
30
- return (
31
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
32
- <WithActions actions={running && onKill ? <KillShellAction onKill={onKill} /> : null}>
33
- <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : 'magenta'}>
34
- <button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
35
- {name}
36
- </button>
37
- {status ? <Ink tone={failed ? 'red' : running ? 'magenta' : 'dim'}> · {status}</Ink> : null}
38
- {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
39
- </Row>
40
- </WithActions>
41
- </TerminalSurface>
42
- )
43
- }
44
-
45
- return (
46
- <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
47
- <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
48
- <ArrowLeft className="size-3.5" />
49
- Back
50
- </Button>
51
- <span className={cn('min-w-0 flex-1 truncate font-mono text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>{name}</span>
52
- {status ? (
53
- <span className={cn('shrink-0 text-label', failed ? 'text-danger' : running ? 'text-[var(--wd-shell-accent)]' : 'text-fg-3')}>
54
- {status}
55
- </span>
30
+ const actions = terminal ? (
31
+ <>
32
+ {grant && shell ? <AgentWriteAction granted={granted} label={shellAgentWriteLabel(shell)} onToggle={grant} /> : null}
33
+ {running && onKill ? <KillShellAction onKill={onKill} /> : null}
34
+ </>
35
+ ) : (
36
+ <>
37
+ {grant && shell ? (
38
+ <button
39
+ type="button"
40
+ aria-label={shellAgentWriteLabel(shell)}
41
+ aria-pressed={granted}
42
+ title={shellAgentWriteLabel(shell)}
43
+ className={cn('shrink-0 text-label', granted ? 'text-warning' : 'text-fg-3 hover:text-fg-1')}
44
+ onClick={grant}
45
+ >
46
+ <AgentWriteIcon granted={granted} className="size-3.5" />
47
+ </button>
56
48
  ) : null}
57
- {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
58
49
  {running && onKill ? (
59
50
  <button
60
51
  type="button"
@@ -66,6 +57,22 @@ export function ShellStrip({ shell, label, cols, onBack, onKill, terminal, fontS
66
57
  <X className="size-3.5" />
67
58
  </button>
68
59
  ) : null}
69
- </div>
60
+ </>
61
+ )
62
+
63
+ return (
64
+ <FrameStrip
65
+ kind="shell"
66
+ name={name}
67
+ status={status}
68
+ detail={detail}
69
+ failed={failed}
70
+ live={running}
71
+ onBack={onBack}
72
+ actions={actions}
73
+ terminal={terminal}
74
+ fontSize={fontSize}
75
+ lineHeight={lineHeight}
76
+ />
70
77
  )
71
78
  }
@@ -126,7 +126,7 @@ export function ShellTerminal({ handle, shellId, onEnded, onShell, fontSize, cla
126
126
  }, [ready])
127
127
 
128
128
  return (
129
- <div className={cn('relative min-h-0 flex-1 overflow-hidden px-2 py-1', className)}>
129
+ <div className={cn('wd-shell-term relative min-h-0 flex-1 overflow-hidden px-2 py-1', className)}>
130
130
  <div ref={host} className="h-full w-full" onClick={() => term.current?.focus()} />
131
131
  {ready && shell.status !== 'idle' ? null : (
132
132
  <div className="absolute inset-0 grid place-items-center">
@@ -1,14 +1,10 @@
1
- import { ArrowLeft } from 'lucide-react'
2
1
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { cn } from '../../lib/utils.ts'
4
2
  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
3
  import { taskBusy, taskFailed, taskIdentity } from '../terminal/tool-run.ts'
9
4
  import type { ToolCallItem } from '../terminal/blocks.ts'
10
5
  import { usePulse } from './pulse.tsx'
11
6
  import { useTicker } from '../terminal/items.tsx'
7
+ import { FrameStrip } from './FrameStrip.tsx'
12
8
 
13
9
  export function SubagentStrip({
14
10
  task,
@@ -39,31 +35,18 @@ export function SubagentStrip({
39
35
  const status = !task ? undefined : failed ? 'failed' : busy ? `${pulse} working…` : 'done'
40
36
  const detail = [tools > 0 ? `${tools} tool${tools === 1 ? '' : 's'}` : undefined, elapsed].filter(Boolean).join(' · ')
41
37
 
42
- if (terminal) {
43
- return (
44
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
45
- <button type="button" onClick={onBack} aria-label="Back to the session" className="block w-full cursor-pointer text-left">
46
- <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : 'green'}>
47
- {name}
48
- {status ? <Ink tone={failed ? 'red' : busy ? 'mark' : 'dim'}> · {status}</Ink> : null}
49
- {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
50
- </Row>
51
- </button>
52
- </TerminalSurface>
53
- )
54
- }
55
-
56
38
  return (
57
- <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
58
- <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
59
- <ArrowLeft className="size-3.5" />
60
- Back
61
- </Button>
62
- <span className={cn('min-w-0 flex-1 truncate text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>{name}</span>
63
- {status ? (
64
- <span className={cn('shrink-0 text-label', failed ? 'text-danger' : busy ? 'text-accent' : 'text-fg-3')}>{status}</span>
65
- ) : null}
66
- {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
67
- </div>
39
+ <FrameStrip
40
+ kind="subagent"
41
+ name={name}
42
+ status={status}
43
+ detail={detail}
44
+ failed={failed}
45
+ live={busy}
46
+ onBack={onBack}
47
+ terminal={terminal}
48
+ fontSize={fontSize}
49
+ lineHeight={lineHeight}
50
+ />
68
51
  )
69
52
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useState } from 'react'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { ChevronDown, Clock } from 'lucide-react'
4
4
  import { Badge } from '../ui/Badge.tsx'
@@ -9,27 +9,17 @@ import { toolInputPreview } from '../../lib/format.ts'
9
9
  import { toolIcon } from '../../lib/tool-icon.ts'
10
10
  import { useToolResultFetcher } from './tool-result-fetch.tsx'
11
11
  import { useToolTitle } from './tool-titles.tsx'
12
- import { useToolResultImageSrc } from './tool-result-image.tsx'
13
- import { IMAGE_UNAVAILABLE, imagePlaceholder } from '../terminal/image-box.ts'
12
+ import { useHostImageSrc, useToolResultImageSrc, type HostImageLoader } from './tool-result-image.tsx'
13
+ import { ViewableImage } from './image-viewer.tsx'
14
+ import { IMAGE_UNAVAILABLE, baseName, hostImagePathOf, imagePlaceholder, resultImageName } from '../terminal/image-box.ts'
14
15
 
15
16
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
16
17
 
17
18
  const RESULT_PREVIEW_CHARS = 2000
18
19
 
19
- const IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
20
-
21
- function imagePathOf(item: ToolCallItem): string | undefined {
22
- if (!IMAGE_TOOLS.has(item.name)) {
23
- return undefined
24
- }
25
- const input = item.input as { savedPath?: unknown; path?: unknown } | null
26
- const path = input?.savedPath ?? input?.path
27
- return typeof path === 'string' ? path : undefined
28
- }
29
-
30
20
  export interface ToolCallCardProps {
31
21
  item: ToolCallItem
32
- hostImage?: (path: string) => Promise<string | undefined>
22
+ hostImage?: HostImageLoader
33
23
  className?: string
34
24
  }
35
25
 
@@ -106,7 +96,7 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
106
96
  const [fullResult, setFullResult] = useState(false)
107
97
  const [fetching, setFetching] = useState(false)
108
98
  const fetchResult = useToolResultFetcher()
109
- const imagePath = imagePathOf(item)
99
+ const imagePath = hostImagePathOf(item)
110
100
  const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
111
101
  const badge = STATE_BADGE[status]
112
102
  const isError = status === 'failed' || item.result?.isError === true
@@ -155,12 +145,12 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
155
145
  </div>
156
146
  ) : null
157
147
 
158
- const image = imagePath && hostImage ? <HostImage path={imagePath} load={hostImage} /> : null
148
+ const image = imagePath ? <HostImage path={imagePath} load={hostImage} /> : null
159
149
 
160
150
  const resultImages = item.result?.images?.length ? (
161
151
  <div className="flex flex-col gap-2 border-t border-border p-2.5">
162
152
  {item.result.images.map((ref) => (
163
- <ResultImage key={ref.partIndex} toolUseId={item.id} image={ref} />
153
+ <ResultImage key={ref.partIndex} toolUseId={item.id} name={resultImageName(item.name, ref)} image={ref} />
164
154
  ))}
165
155
  </div>
166
156
  ) : null
@@ -209,12 +199,14 @@ function PlainPayload({ code, label, className }: { code: string; label: string;
209
199
 
210
200
  type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
211
201
 
212
- function ResultImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
202
+ function ResultImage({ toolUseId, name, image }: { toolUseId: string; name: string; image: ToolResultImage }) {
213
203
  const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
214
204
  return (
215
205
  <div className="flex h-60 items-start overflow-hidden rounded-md border border-border bg-surface-hover">
216
206
  {src ? (
217
- <img src={src} alt={imagePlaceholder(image)} className="h-full max-w-full object-contain" />
207
+ <ViewableImage image={{ src, name }} className="h-full max-w-full">
208
+ <img src={src} alt={name} className="h-full max-w-full object-contain" />
209
+ </ViewableImage>
218
210
  ) : (
219
211
  <span className="p-2 text-label text-fg-4">{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</span>
220
212
  )}
@@ -222,32 +214,17 @@ function ResultImage({ toolUseId, image }: { toolUseId: string; image: ToolResul
222
214
  )
223
215
  }
224
216
 
225
- function HostImage({ path, load }: { path: string; load: (path: string) => Promise<string | undefined> }) {
226
- const [src, setSrc] = useState<string | undefined>()
227
- useEffect(() => {
228
- let cancelled = false
229
- setSrc(undefined)
230
- load(path)
231
- .then((url) => {
232
- if (!cancelled) {
233
- setSrc(url)
234
- }
235
- })
236
- .catch(() => {})
237
- return () => {
238
- cancelled = true
239
- }
240
- }, [path, load])
217
+ function HostImage({ path, load }: { path: string; load?: HostImageLoader }) {
218
+ const { src } = useHostImageSrc(path, load)
241
219
  if (!src) {
242
220
  return null
243
221
  }
222
+ const name = baseName(path)
244
223
  return (
245
224
  <div className="border-t border-border p-2.5">
246
- <img
247
- src={src}
248
- alt={path.split('/').pop() ?? 'Generated image'}
249
- className="max-h-96 w-auto max-w-full rounded-md border border-border"
250
- />
225
+ <ViewableImage image={{ src, name }} className="block max-w-full">
226
+ <img src={src} alt={name} className="max-h-96 w-auto max-w-full rounded-md border border-border" />
227
+ </ViewableImage>
251
228
  </div>
252
229
  )
253
230
  }