@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
@@ -0,0 +1,326 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from 'react'
13
+ import { Download, Maximize, Minus, Plus, X } from 'lucide-react'
14
+ import { Button } from '../ui/Button.tsx'
15
+ import { cn } from '../../lib/utils.ts'
16
+
17
+ export type ViewedImage = { src: string; name: string }
18
+
19
+ export type OpenImage = (image: ViewedImage) => void
20
+
21
+ type Zoom = number | 'fit'
22
+
23
+ type Size = { width: number; height: number }
24
+
25
+ type Anchor = { x: number; y: number; viewX: number; viewY: number }
26
+
27
+ type Focus = { clientX: number; clientY: number }
28
+
29
+ const ZOOM_STEPS = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6, 8]
30
+
31
+ const STAGE_PADDING = 16
32
+
33
+ const DRAG_SLOP = 4
34
+
35
+ const WHEEL_ZOOM_RATE = 0.01
36
+
37
+ const WHEEL_DELTA_CAP = 25
38
+
39
+ const ViewerContext = createContext<OpenImage | undefined>(undefined)
40
+
41
+ export function useOpenImage(): OpenImage | undefined {
42
+ return useContext(ViewerContext)
43
+ }
44
+
45
+ export function ImageViewerProvider({ children }: { children: ReactNode }) {
46
+ const [image, setImage] = useState<ViewedImage>()
47
+ const close = useCallback(() => setImage(undefined), [])
48
+ return (
49
+ <ViewerContext.Provider value={setImage}>
50
+ {children}
51
+ {image ? <ImageViewer key={image.src} image={image} onClose={close} /> : null}
52
+ </ViewerContext.Provider>
53
+ )
54
+ }
55
+
56
+ export function ViewableImage({ image, className, children }: { image: ViewedImage; className?: string; children: ReactNode }) {
57
+ const open = useOpenImage()
58
+ if (!open) {
59
+ return <>{children}</>
60
+ }
61
+ return (
62
+ <button
63
+ type="button"
64
+ aria-label={`View ${image.name}`}
65
+ title={image.name}
66
+ data-slot="viewable-image"
67
+ className={cn('cursor-zoom-in', className)}
68
+ onClick={(event) => {
69
+ event.stopPropagation()
70
+ open(image)
71
+ }}
72
+ >
73
+ {children}
74
+ </button>
75
+ )
76
+ }
77
+
78
+ export function ImageViewer({ image, onClose, className }: { image: ViewedImage; onClose: () => void; className?: string }) {
79
+ const rootRef = useRef<HTMLDivElement>(null)
80
+ const stageRef = useRef<HTMLDivElement>(null)
81
+ const [natural, setNatural] = useState<Size>()
82
+ const [stage, setStage] = useState<Size>()
83
+ const [zoom, setZoom] = useState<Zoom>('fit')
84
+ const [failed, setFailed] = useState(false)
85
+ const anchor = useRef<Anchor | undefined>(undefined)
86
+ const wheelScale = useRef<number | undefined>(undefined)
87
+ const drag = useRef<{ x: number; y: number; left: number; top: number } | undefined>(undefined)
88
+ const dragged = useRef(false)
89
+
90
+ const fitScale =
91
+ natural && stage
92
+ ? Math.min(1, (stage.width - STAGE_PADDING * 2) / natural.width, (stage.height - STAGE_PADDING * 2) / natural.height)
93
+ : 1
94
+ const scale = zoom === 'fit' ? Math.max(fitScale, 0.01) : zoom
95
+ const scrollable =
96
+ natural !== undefined && stage !== undefined && (natural.width * scale > stage.width || natural.height * scale > stage.height)
97
+
98
+ useEffect(() => {
99
+ const previous = document.activeElement as HTMLElement | null
100
+ rootRef.current?.focus()
101
+ return () => previous?.focus?.()
102
+ }, [])
103
+
104
+ useEffect(() => {
105
+ const element = stageRef.current
106
+ if (!element) {
107
+ return
108
+ }
109
+ const observer = new ResizeObserver(() => setStage({ width: element.clientWidth, height: element.clientHeight }))
110
+ observer.observe(element)
111
+ return () => observer.disconnect()
112
+ }, [])
113
+
114
+ useLayoutEffect(() => {
115
+ const element = stageRef.current
116
+ const point = anchor.current
117
+ if (!element || !point) {
118
+ return
119
+ }
120
+ anchor.current = undefined
121
+ wheelScale.current = undefined
122
+ element.scrollLeft = point.x * element.scrollWidth - point.viewX
123
+ element.scrollTop = point.y * element.scrollHeight - point.viewY
124
+ }, [scale])
125
+
126
+ const zoomTo = useCallback((next: Zoom, focus?: Focus) => {
127
+ const element = stageRef.current
128
+ if (element && element.scrollWidth > 0 && element.scrollHeight > 0) {
129
+ const rect = element.getBoundingClientRect()
130
+ const viewX = focus ? focus.clientX - rect.left : element.clientWidth / 2
131
+ const viewY = focus ? focus.clientY - rect.top : element.clientHeight / 2
132
+ anchor.current = {
133
+ x: (element.scrollLeft + viewX) / element.scrollWidth,
134
+ y: (element.scrollTop + viewY) / element.scrollHeight,
135
+ viewX,
136
+ viewY,
137
+ }
138
+ }
139
+ setZoom(next)
140
+ }, [])
141
+
142
+ const step = (direction: 1 | -1) => {
143
+ const next =
144
+ direction > 0 ? ZOOM_STEPS.find((value) => value > scale + 0.001) : ZOOM_STEPS.filter((value) => value < scale - 0.001).at(-1)
145
+ zoomTo(next ?? (direction > 0 ? ZOOM_STEPS[ZOOM_STEPS.length - 1] : ZOOM_STEPS[0]))
146
+ }
147
+
148
+ const onKeyDown = (event: ReactKeyboardEvent) => {
149
+ event.stopPropagation()
150
+ if (event.key === 'Escape') {
151
+ event.preventDefault()
152
+ onClose()
153
+ } else if (event.key === '+' || event.key === '=') {
154
+ event.preventDefault()
155
+ step(1)
156
+ } else if (event.key === '-' || event.key === '_') {
157
+ event.preventDefault()
158
+ step(-1)
159
+ } else if (event.key === '0') {
160
+ event.preventDefault()
161
+ zoomTo('fit')
162
+ } else if (event.key === '1') {
163
+ event.preventDefault()
164
+ zoomTo(1)
165
+ }
166
+ }
167
+
168
+ const scaleRef = useRef(scale)
169
+ scaleRef.current = scale
170
+
171
+ useEffect(() => {
172
+ const element = stageRef.current
173
+ if (!element) {
174
+ return
175
+ }
176
+ const onWheel = (event: WheelEvent) => {
177
+ if (!event.ctrlKey && !event.metaKey) {
178
+ return
179
+ }
180
+ event.preventDefault()
181
+ const delta = Math.max(-WHEEL_DELTA_CAP, Math.min(WHEEL_DELTA_CAP, event.deltaY))
182
+ const base = wheelScale.current ?? scaleRef.current
183
+ const next = Math.min(ZOOM_STEPS[ZOOM_STEPS.length - 1]!, Math.max(ZOOM_STEPS[0]!, base * Math.exp(-delta * WHEEL_ZOOM_RATE)))
184
+ wheelScale.current = next
185
+ zoomTo(next, event)
186
+ }
187
+ element.addEventListener('wheel', onWheel, { passive: false })
188
+ return () => element.removeEventListener('wheel', onWheel)
189
+ }, [zoomTo])
190
+
191
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
192
+ const element = stageRef.current
193
+ if (!scrollable || !element || event.button !== 0) {
194
+ return
195
+ }
196
+ dragged.current = false
197
+ drag.current = { x: event.clientX, y: event.clientY, left: element.scrollLeft, top: element.scrollTop }
198
+ element.setPointerCapture(event.pointerId)
199
+ }
200
+
201
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
202
+ const element = stageRef.current
203
+ const start = drag.current
204
+ if (!element || !start) {
205
+ return
206
+ }
207
+ if (Math.abs(event.clientX - start.x) + Math.abs(event.clientY - start.y) > DRAG_SLOP) {
208
+ dragged.current = true
209
+ }
210
+ element.scrollLeft = start.left - (event.clientX - start.x)
211
+ element.scrollTop = start.top - (event.clientY - start.y)
212
+ }
213
+
214
+ const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
215
+ drag.current = undefined
216
+ stageRef.current?.releasePointerCapture?.(event.pointerId)
217
+ }
218
+
219
+ return (
220
+ <div
221
+ ref={rootRef}
222
+ role="dialog"
223
+ aria-modal="true"
224
+ aria-label={image.name}
225
+ tabIndex={-1}
226
+ data-slot="image-viewer"
227
+ onKeyDown={onKeyDown}
228
+ onClick={(event) => event.stopPropagation()}
229
+ className={cn('absolute inset-0 z-50 flex flex-col bg-bg outline-none', className)}
230
+ >
231
+ <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
232
+ <div className="min-w-0 flex-1 px-1">
233
+ <div className="truncate font-mono text-body-sm text-fg-1" title={image.name}>
234
+ {image.name}
235
+ </div>
236
+ {natural ? (
237
+ <div className="text-label text-fg-4">
238
+ {natural.width} × {natural.height}
239
+ </div>
240
+ ) : null}
241
+ </div>
242
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom out" title="Zoom out (-)" onClick={() => step(-1)} disabled={failed}>
243
+ <Minus />
244
+ </Button>
245
+ <span className="w-12 text-center font-mono text-label text-fg-3 tabular-nums">{Math.round(scale * 100)}%</span>
246
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom in" title="Zoom in (+)" onClick={() => step(1)} disabled={failed}>
247
+ <Plus />
248
+ </Button>
249
+ <Button
250
+ variant={zoom === 'fit' ? 'secondary' : 'ghost'}
251
+ size="icon-sm"
252
+ aria-label="Fit to view"
253
+ aria-pressed={zoom === 'fit'}
254
+ title="Fit to view (0)"
255
+ onClick={() => zoomTo('fit')}
256
+ disabled={failed}
257
+ >
258
+ <Maximize />
259
+ </Button>
260
+ <Button
261
+ variant={zoom === 1 ? 'secondary' : 'ghost'}
262
+ size="sm"
263
+ aria-label="Actual size"
264
+ aria-pressed={zoom === 1}
265
+ title="Actual size (1)"
266
+ onClick={() => zoomTo(1)}
267
+ disabled={failed}
268
+ className="font-mono"
269
+ >
270
+ 1:1
271
+ </Button>
272
+ <a
273
+ href={image.src}
274
+ download={image.name}
275
+ aria-label="Download"
276
+ title="Download"
277
+ className="inline-flex size-7 items-center justify-center rounded-md text-fg-2 transition-colors hover:bg-surface-hover hover:text-foreground"
278
+ >
279
+ <Download className="size-4" />
280
+ </a>
281
+ <Button variant="ghost" size="icon-sm" aria-label="Close" title="Close (Esc)" onClick={onClose}>
282
+ <X />
283
+ </Button>
284
+ </div>
285
+ <div
286
+ ref={stageRef}
287
+ data-slot="image-viewer-stage"
288
+ onPointerDown={onPointerDown}
289
+ onPointerMove={onPointerMove}
290
+ onPointerUp={endDrag}
291
+ onPointerCancel={endDrag}
292
+ onClick={(event) => {
293
+ if (dragged.current) {
294
+ dragged.current = false
295
+ return
296
+ }
297
+ if (event.target === event.currentTarget || event.target === event.currentTarget.firstElementChild) {
298
+ onClose()
299
+ }
300
+ }}
301
+ className={cn('min-h-0 flex-1 overflow-auto bg-surface-hover/40', scrollable && 'cursor-grab active:cursor-grabbing')}
302
+ >
303
+ <div className="flex min-h-full w-max min-w-full items-center justify-center" style={{ padding: STAGE_PADDING }}>
304
+ {failed ? (
305
+ <span className="text-body-sm text-fg-4">Image could not be displayed</span>
306
+ ) : (
307
+ <img
308
+ src={image.src}
309
+ alt={image.name}
310
+ draggable={false}
311
+ onLoad={(event) => setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
312
+ onError={() => setFailed(true)}
313
+ onDoubleClick={() => zoomTo(zoom === 'fit' ? 1 : 'fit')}
314
+ className="block max-w-none select-none"
315
+ style={
316
+ natural
317
+ ? { width: natural.width * scale, height: natural.height * scale, imageRendering: scale > 2 ? 'pixelated' : undefined }
318
+ : { maxWidth: '100%', maxHeight: '100%', visibility: 'hidden' }
319
+ }
320
+ />
321
+ )}
322
+ </div>
323
+ </div>
324
+ </div>
325
+ )
326
+ }
@@ -0,0 +1,139 @@
1
+ import { defaultRehypePlugins, type StreamdownProps } from 'streamdown'
2
+ import { parseFileLink } from '../../lib/file-link.ts'
3
+ import { useFileLinks } from '../terminal/file-link.tsx'
4
+ import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, baseName } from '../terminal/image-box.ts'
5
+ import { ViewableImage } from './image-viewer.tsx'
6
+ import { useHostImageSrc } from './tool-result-image.tsx'
7
+
8
+ // Streamdown's sanitize drops `file:` sources and its harden step blocks or re-roots relative ones, so
9
+ // a host path is carried through both as an address on a reserved, never-resolving host.
10
+ const LOCAL_PREFIX = 'https://local-image.invalid/'
11
+
12
+ const WEB_SOURCE = /^(?:https?:|data:image\/|blob:)/i
13
+
14
+ const REMOTE_SOURCE = /^https?:/i
15
+
16
+ export const REMOTE_IMAGE_NOTICE = 'remote image, not loaded'
17
+
18
+ type HastNode = { type: string; tagName?: string; properties?: Record<string, unknown>; children?: HastNode[] }
19
+
20
+ function shieldLocalImages() {
21
+ return (tree: HastNode) => {
22
+ walk(tree)
23
+ }
24
+ }
25
+
26
+ function walk(node: HastNode): void {
27
+ if (node.type === 'element' && node.tagName === 'img' && node.properties) {
28
+ const src = node.properties.src
29
+ if (typeof src === 'string' && src.trim() && !WEB_SOURCE.test(src.trim())) {
30
+ node.properties.src = LOCAL_PREFIX + encodeURIComponent(src.trim())
31
+ }
32
+ }
33
+ for (const child of node.children ?? []) {
34
+ walk(child)
35
+ }
36
+ }
37
+
38
+ export const MARKDOWN_REHYPE_PLUGINS: NonNullable<StreamdownProps['rehypePlugins']> = [
39
+ shieldLocalImages,
40
+ ...Object.values(defaultRehypePlugins),
41
+ ]
42
+
43
+ export function localImagePath(src: string | undefined): string | undefined {
44
+ if (!src?.startsWith(LOCAL_PREFIX)) {
45
+ return undefined
46
+ }
47
+ try {
48
+ return decodeURIComponent(src.slice(LOCAL_PREFIX.length))
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ // A remote image loads the moment it renders, so a transcript an agent was talked into writing could beacon out
55
+ // whatever it can read. It is shown as a link instead: opening it is the user's decision, and a navigation, not a load.
56
+ export function remoteImageHost(src: string | undefined): string | undefined {
57
+ if (!src || !REMOTE_SOURCE.test(src.trim()) || localImagePath(src) !== undefined) {
58
+ return undefined
59
+ }
60
+ try {
61
+ return new URL(src.trim()).host
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ export function MarkdownImage({ src, alt, terminal }: { src?: unknown; alt?: unknown; terminal?: boolean }) {
68
+ const raw = typeof src === 'string' ? src : undefined
69
+ const remoteHost = remoteImageHost(raw)
70
+ if (remoteHost !== undefined) {
71
+ return <RemoteImageLink href={raw!.trim()} host={remoteHost} alt={alt} terminal={terminal} />
72
+ }
73
+ return <LoadedMarkdownImage raw={raw} alt={alt} terminal={terminal} />
74
+ }
75
+
76
+ function RemoteImageLink({ href, host, alt, terminal }: { href: string; host: string; alt?: unknown; terminal?: boolean }) {
77
+ const label = typeof alt === 'string' && alt ? `${alt} · ` : ''
78
+ const text = `${label}${host} (${REMOTE_IMAGE_NOTICE})`
79
+ if (terminal) {
80
+ return (
81
+ <a href={href} target="_blank" rel="noopener noreferrer" data-tone="faint" title={href}>
82
+ {text}
83
+ </a>
84
+ )
85
+ }
86
+ return (
87
+ <a
88
+ href={href}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ title={href}
92
+ className="my-1 inline-block rounded-md border border-border bg-surface-hover px-2 py-1 text-label text-fg-4"
93
+ >
94
+ {text}
95
+ </a>
96
+ )
97
+ }
98
+
99
+ function LoadedMarkdownImage({ raw, alt, terminal }: { raw?: string; alt?: unknown; terminal?: boolean }) {
100
+ const links = useFileLinks()
101
+ const local = localImagePath(raw)
102
+ const path = local === undefined ? undefined : parseFileLink(local, links?.cwd)?.path
103
+ const host = useHostImageSrc(path)
104
+ const resolved = local === undefined ? raw : host.src
105
+ const label = typeof alt === 'string' && alt ? alt : undefined
106
+ const name = path ? baseName(path) : (label ?? 'image')
107
+ const failed = local !== undefined && (path === undefined || host.failed)
108
+
109
+ if (terminal) {
110
+ return (
111
+ <span
112
+ className="term-image"
113
+ data-state={resolved ? 'loaded' : failed ? 'failed' : 'pending'}
114
+ style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
115
+ >
116
+ {resolved ? (
117
+ <ViewableImage image={{ src: resolved, name }}>
118
+ <img src={resolved} alt={label ?? name} />
119
+ </ViewableImage>
120
+ ) : (
121
+ <span data-tone="faint">{failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}</span>
122
+ )}
123
+ </span>
124
+ )
125
+ }
126
+
127
+ if (!resolved) {
128
+ return (
129
+ <span className="my-1 inline-block rounded-md border border-border bg-surface-hover px-2 py-1 text-label text-fg-4">
130
+ {failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}
131
+ </span>
132
+ )
133
+ }
134
+ return (
135
+ <ViewableImage image={{ src: resolved, name }} className="my-1 block max-w-full">
136
+ <img src={resolved} alt={label ?? name} className="max-h-96 w-auto max-w-full rounded-md border border-border" />
137
+ </ViewableImage>
138
+ )
139
+ }
@@ -8,9 +8,12 @@ export type MarkKind = 'user' | 'subagent' | 'shell' | 'turn' | 'turnFailed' | '
8
8
  export type Mark = {
9
9
  kind: MarkKind
10
10
  itemIndex: number
11
+ rowIndex?: number
11
12
  turnIndex?: number
12
13
  }
13
14
 
15
+ export type Placement = (mark: Mark) => { y: number; h: number }
16
+
14
17
  export type Cluster = {
15
18
  lane: Lane
16
19
  kind: MarkKind
@@ -106,11 +109,30 @@ export interface BuildMarksOptions {
106
109
  frameParentId?: string
107
110
  bookmarks?: readonly number[]
108
111
  recapItemIndex?: number
112
+ rowIndexFor?: (itemIndex: number) => number
113
+ }
114
+
115
+ export function toolCallFailed(item: TranscriptItem): boolean {
116
+ return item.kind === 'tool_call' && (item.status === 'failed' || item.result?.isError === true)
117
+ }
118
+
119
+ export function rowOutcomes(
120
+ items: readonly TranscriptItem[],
121
+ frameParentId: string | undefined,
122
+ rowIndexFor: (itemIndex: number) => number,
123
+ ): Set<number> {
124
+ const outcome = new Map<number, number>()
125
+ items.forEach((item, index) => {
126
+ if (item.kind === 'tool_call' && parentOf(item) === frameParentId) {
127
+ outcome.set(rowIndexFor(index), index)
128
+ }
129
+ })
130
+ return new Set(outcome.values())
109
131
  }
110
132
 
111
133
  export function buildMarks(
112
134
  items: readonly TranscriptItem[],
113
- { frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
135
+ { frameParentId, bookmarks = [], recapItemIndex, rowIndexFor = (index) => index }: BuildMarksOptions = {},
114
136
  ): Mark[] {
115
137
  const marks: Mark[] = []
116
138
  const subagentParents = new Set<string>()
@@ -120,6 +142,7 @@ export function buildMarks(
120
142
  subagentParents.add(parent)
121
143
  }
122
144
  }
145
+ const outcomes = rowOutcomes(items, frameParentId, rowIndexFor)
123
146
  let segment: Segment = {}
124
147
  const closeSegment = () => {
125
148
  const anchor = segment.response ?? segment.turn
@@ -147,11 +170,7 @@ export function buildMarks(
147
170
  marks.push({ kind: 'error', itemIndex: index })
148
171
  } else if (item.kind === 'shell') {
149
172
  marks.push({ kind: 'shell', itemIndex: index })
150
- } else if (
151
- item.kind === 'tool_call' &&
152
- parentOf(item) === frameParentId &&
153
- (item.status === 'failed' || item.result?.isError === true)
154
- ) {
173
+ } else if (toolCallFailed(item) && outcomes.has(index)) {
155
174
  marks.push({ kind: 'toolFailed', itemIndex: index })
156
175
  } else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
157
176
  if (frameParentId !== undefined) {
@@ -173,22 +192,25 @@ export function buildMarks(
173
192
  return marks
174
193
  }
175
194
 
176
- export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
195
+ export function proportionalPlacement(railH: number, itemCount: number): Placement {
177
196
  const count = Math.max(1, itemCount)
178
197
  const h = Math.max(MIN_MARK, Math.round(railH / count))
179
- const lanes = new Map<Lane, { mark: Mark; y: number }[]>()
198
+ return (mark) => ({ y: Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH)), h })
199
+ }
200
+
201
+ export function clusterMarks(marks: readonly Mark[], place: Placement): Cluster[] {
202
+ const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
180
203
  for (const mark of marks) {
181
- const y = Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH))
182
204
  const lane = LANE[mark.kind]
183
205
  const list = lanes.get(lane) ?? []
184
- list.push({ mark, y })
206
+ list.push({ mark, ...place(mark) })
185
207
  lanes.set(lane, list)
186
208
  }
187
209
  const clusters: Cluster[] = []
188
210
  for (const [lane, list] of lanes) {
189
211
  list.sort((a, b) => a.y - b.y)
190
212
  let current: Cluster | null = null
191
- for (const { mark, y } of list) {
213
+ for (const { mark, y, h } of list) {
192
214
  if (current && y <= current.y + current.h + 1) {
193
215
  current.h = Math.max(current.h, y + h - current.y)
194
216
  if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {
@@ -0,0 +1,122 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { PermissionRequest } from '@workerdeck/protocol'
3
+ import type { TranscriptItem } from '@workerdeck/react'
4
+ import { toolInputPreview } from '../../lib/format.ts'
5
+ import { permissionPromptModel } from '../../lib/permission-prompt.ts'
6
+ import { doneLine, excerpt, KIND_NAME, type Cluster, type Mark } from './scrubber-marks.ts'
7
+
8
+ export type PeekSkin = {
9
+ excerptClass: string
10
+ muted: string
11
+ danger: string
12
+ glyph: string
13
+ text?: string
14
+ strong?: string
15
+ detail?: string
16
+ answerGlyph?: string
17
+ }
18
+
19
+ export const CARD_PEEK: PeekSkin = { excerptClass: 'wd-scrub-ex', muted: 'muted', danger: 'danger', glyph: 'muted', detail: 'muted' }
20
+
21
+ export const TERMINAL_PEEK: PeekSkin = {
22
+ excerptClass: 'term-scrub-ex',
23
+ muted: 'faint',
24
+ danger: 'red',
25
+ glyph: 'dim',
26
+ text: 'fg',
27
+ strong: 'bright',
28
+ detail: 'fg',
29
+ answerGlyph: '● ',
30
+ }
31
+
32
+ export interface PeekInput {
33
+ cluster: Cluster
34
+ first: Mark | undefined
35
+ items: readonly TranscriptItem[]
36
+ pendingApprovals: readonly PermissionRequest[]
37
+ recapLabel?: string
38
+ }
39
+
40
+ export function peekContent({ cluster, first, items, pendingApprovals, recapLabel }: PeekInput, skin: PeekSkin): ReactNode {
41
+ const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
42
+ const kind = first?.kind ?? cluster.kind
43
+ return (
44
+ <>
45
+ <div data-tone={skin.muted}>
46
+ {KIND_NAME[kind]}
47
+ {more}
48
+ </div>
49
+ {cluster.kind === 'approval'
50
+ ? approvalBody(pendingApprovals[0], skin)
51
+ : kind === 'recap'
52
+ ? recapBody(recapLabel, skin)
53
+ : markBody(first, items, skin)}
54
+ </>
55
+ )
56
+ }
57
+
58
+ function approvalBody(request: PermissionRequest | undefined, skin: PeekSkin): ReactNode {
59
+ if (!request) {
60
+ return null
61
+ }
62
+ return (
63
+ <>
64
+ <div data-tone={skin.strong}>{permissionPromptModel(request).heading}</div>
65
+ <div className={skin.excerptClass} data-tone={skin.detail}>
66
+ {`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
67
+ </div>
68
+ </>
69
+ )
70
+ }
71
+
72
+ function recapBody(label: string | undefined, skin: PeekSkin): ReactNode {
73
+ return label === undefined ? null : <div data-tone={skin.muted}>※ {label}</div>
74
+ }
75
+
76
+ function markBody(first: Mark | undefined, items: readonly TranscriptItem[], skin: PeekSkin): ReactNode {
77
+ if (!first) {
78
+ return null
79
+ }
80
+ const item = items[first.itemIndex]
81
+ if (first.kind === 'turn' || first.kind === 'turnFailed') {
82
+ const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
83
+ return (
84
+ <>
85
+ {item?.kind === 'assistant_text' ? (
86
+ <div className={skin.excerptClass} data-tone={skin.text}>
87
+ {skin.answerGlyph ? <span data-tone={skin.glyph}>{skin.answerGlyph}</span> : null}
88
+ {item.text}
89
+ </div>
90
+ ) : null}
91
+ {turn?.kind === 'turn_result' ? (
92
+ <>
93
+ <div data-tone={turn.isError ? skin.danger : skin.muted}>{doneLine(turn)}</div>
94
+ {turn.errors?.map((message, index) => (
95
+ <div key={index} data-tone={skin.danger}>
96
+ {message}
97
+ </div>
98
+ ))}
99
+ </>
100
+ ) : null}
101
+ </>
102
+ )
103
+ }
104
+ if (!item) {
105
+ return null
106
+ }
107
+ const failure =
108
+ first.kind === 'toolFailed' && item.kind === 'tool_call' ? item.result?.text.split('\n').find((line) => line.trim() !== '') : undefined
109
+ return (
110
+ <>
111
+ <div className={skin.excerptClass} data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? skin.danger : skin.text}>
112
+ {first.kind === 'user' ? <span data-tone={skin.glyph}>{'❯ '}</span> : null}
113
+ {excerpt(item)}
114
+ </div>
115
+ {failure ? (
116
+ <div className={skin.excerptClass} data-tone={skin.danger}>
117
+ {failure}
118
+ </div>
119
+ ) : null}
120
+ </>
121
+ )
122
+ }