@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.
- package/README.md +6 -5
- package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
- package/build/index.d.mts +194 -49
- package/build/index.mjs +562 -260
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +170 -21
- package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
- package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
- package/build/workspace.d.mts +3 -3
- package/build/workspace.mjs +3 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/ApprovalPrompts.tsx +87 -0
- package/src/components/agent/Composer.tsx +25 -47
- package/src/components/agent/ContextDialog.tsx +75 -67
- package/src/components/agent/FrameStrip.tsx +74 -0
- package/src/components/agent/HostFilesDialog.tsx +3 -2
- package/src/components/agent/McpDialog.tsx +112 -78
- package/src/components/agent/PermissionPrompt.tsx +22 -17
- package/src/components/agent/QuestionPrompt.tsx +6 -41
- package/src/components/agent/Response.tsx +8 -1
- package/src/components/agent/Scrubber.tsx +6 -75
- package/src/components/agent/SessionBrowser.tsx +131 -214
- package/src/components/agent/SessionFilters.tsx +266 -0
- package/src/components/agent/SessionInfoDialog.tsx +47 -63
- package/src/components/agent/SessionItem.tsx +29 -6
- package/src/components/agent/SessionPanel.tsx +249 -498
- package/src/components/agent/SessionSteps.tsx +98 -14
- package/src/components/agent/SessionWorkspace.tsx +3 -3
- package/src/components/agent/ShellStrip.tsx +43 -36
- package/src/components/agent/ShellTerminal.tsx +1 -1
- package/src/components/agent/SubagentStrip.tsx +13 -30
- package/src/components/agent/ToolCallCard.tsx +18 -41
- package/src/components/agent/Transcript.tsx +111 -93
- package/src/components/agent/TranscriptItemView.tsx +80 -67
- package/src/components/agent/TranscriptRows.tsx +4 -2
- package/src/components/agent/composer-commands.ts +80 -1
- package/src/components/agent/image-viewer.tsx +326 -0
- package/src/components/agent/markdown-image.tsx +139 -0
- package/src/components/agent/scrubber-marks.ts +33 -11
- package/src/components/agent/scrubber-peek.tsx +122 -0
- package/src/components/agent/session-panel-providers.tsx +41 -0
- package/src/components/agent/shell-actions.tsx +38 -1
- package/src/components/agent/tool-result-image.tsx +40 -0
- package/src/components/agent/transcript-variant.tsx +3 -21
- package/src/components/agent/use-catch-up.ts +20 -0
- package/src/components/agent/use-frame.ts +88 -0
- package/src/components/agent/use-host-image.ts +81 -0
- package/src/components/agent/use-shell-frame.ts +12 -70
- package/src/components/agent/use-subagent-frame.ts +19 -75
- package/src/components/prompt-area/prompt-area-engine.ts +21 -20
- package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
- package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
- package/src/components/terminal/PermissionPrompt.tsx +31 -28
- package/src/components/terminal/QuestionPrompt.tsx +10 -46
- package/src/components/terminal/TerminalTranscript.tsx +23 -89
- package/src/components/terminal/affordances.tsx +100 -74
- package/src/components/terminal/height.ts +48 -32
- package/src/components/terminal/image-box.ts +31 -0
- package/src/components/terminal/items.tsx +70 -30
- package/src/components/terminal/markdown.tsx +4 -1
- package/src/components/terminal/scrubber.tsx +25 -272
- package/src/components/terminal/shell-row.ts +17 -3
- package/src/components/ui/Popover.tsx +29 -0
- package/src/components/ui/Select.tsx +25 -0
- package/src/index.ts +65 -12
- package/src/lib/file-link.ts +3 -16
- package/src/lib/permission-prompt.ts +57 -0
- package/src/lib/question-answers.ts +65 -0
- package/src/lib/shell-request.ts +115 -0
- package/src/lib/tool-icon.ts +13 -1
- package/src/styles/terminal.css +129 -22
- package/src/styles/theme.css +4 -0
- package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
- 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
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|