@workerdeck/ui 2.15.0 → 3.0.1
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-C1bU4yTM.d.mts} +11 -11
- package/build/index.d.mts +46 -9
- package/build/index.mjs +70 -43
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +156 -16
- package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-BEWxt0ZM.mjs} +1566 -852
- package/build/use-path-links-BEWxt0ZM.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/PermissionPrompt.tsx +16 -2
- package/src/components/agent/Response.tsx +8 -1
- package/src/components/agent/SessionBrowser.tsx +6 -0
- package/src/components/agent/SessionItem.tsx +3 -0
- package/src/components/agent/SessionPanel.tsx +28 -33
- package/src/components/agent/SessionSteps.tsx +46 -5
- package/src/components/agent/SessionWorkspace.tsx +3 -3
- package/src/components/agent/ShellStrip.tsx +27 -4
- package/src/components/agent/ShellTerminal.tsx +1 -1
- package/src/components/agent/ToolCallCard.tsx +18 -41
- package/src/components/agent/Transcript.tsx +98 -93
- package/src/components/agent/TranscriptItemView.tsx +102 -59
- package/src/components/agent/TranscriptRows.tsx +4 -2
- package/src/components/agent/image-viewer.tsx +326 -0
- package/src/components/agent/markdown-image.tsx +91 -0
- package/src/components/agent/shell-actions.tsx +2 -0
- package/src/components/agent/tool-result-image.tsx +40 -0
- package/src/components/agent/transcript-variant.tsx +3 -21
- package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
- package/src/components/terminal/PermissionPrompt.tsx +20 -4
- package/src/components/terminal/TerminalTranscript.tsx +38 -21
- 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 +92 -14
- package/src/components/terminal/markdown.tsx +4 -1
- package/src/components/terminal/shell-row.ts +17 -3
- package/src/index.ts +19 -4
- 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
|
@@ -11,9 +11,19 @@ import { Response } from './Response.tsx'
|
|
|
11
11
|
import { ToolCallCard } from './ToolCallCard.tsx'
|
|
12
12
|
import { Row } from '../terminal/row.tsx'
|
|
13
13
|
import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
|
|
14
|
+
import { ViewableImage } from './image-viewer.tsx'
|
|
14
15
|
import { peerLabel } from '../terminal/items.tsx'
|
|
15
16
|
import { useShellActions } from './shell-actions.tsx'
|
|
16
|
-
import {
|
|
17
|
+
import { AgentWriteAction, BookmarkAction, CopyAction, KillShellAction, OpenShellAction, WithActions } from '../terminal/affordances.tsx'
|
|
18
|
+
import {
|
|
19
|
+
shellAgentWriteLabel,
|
|
20
|
+
shellBodyLines,
|
|
21
|
+
shellFailed,
|
|
22
|
+
shellFooterText,
|
|
23
|
+
shellGrantable,
|
|
24
|
+
shellLabel,
|
|
25
|
+
shellStatusText,
|
|
26
|
+
} from '../terminal/shell-row.ts'
|
|
17
27
|
|
|
18
28
|
function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
19
29
|
return (
|
|
@@ -67,36 +77,47 @@ export function TranscriptItemView({
|
|
|
67
77
|
terminal?: boolean
|
|
68
78
|
}) {
|
|
69
79
|
if (terminal) {
|
|
70
|
-
return <TerminalItemView item={item} fileUrl={fileUrl} />
|
|
80
|
+
return <TerminalItemView item={item} fileUrl={fileUrl} attachmentUrl={attachmentUrl} />
|
|
71
81
|
}
|
|
72
82
|
switch (item.kind) {
|
|
73
83
|
case 'user': {
|
|
74
84
|
return (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
<WithActions actions={<BookmarkAction id={item.id} />}>
|
|
86
|
+
<Message from="user">
|
|
87
|
+
{item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
|
|
88
|
+
{item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
|
|
89
|
+
{item.text ? (
|
|
90
|
+
<MessageContent>
|
|
91
|
+
<PromptTokenText text={item.text} />
|
|
92
|
+
</MessageContent>
|
|
93
|
+
) : null}
|
|
94
|
+
</Message>
|
|
95
|
+
</WithActions>
|
|
84
96
|
)
|
|
85
97
|
}
|
|
86
98
|
case 'assistant_text': {
|
|
87
99
|
return (
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
<WithActions
|
|
101
|
+
actions={
|
|
102
|
+
<>
|
|
103
|
+
<BookmarkAction id={item.id} />
|
|
104
|
+
{item.streaming ? null : <CopyAction text={item.text} label="Copy message" />}
|
|
105
|
+
</>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
<Message from="assistant">
|
|
109
|
+
<MessageContent>
|
|
110
|
+
<Response streaming={item.streaming}>{item.text}</Response>
|
|
111
|
+
</MessageContent>
|
|
112
|
+
</Message>
|
|
113
|
+
</WithActions>
|
|
93
114
|
)
|
|
94
115
|
}
|
|
95
116
|
case 'thinking': {
|
|
96
117
|
return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
|
|
97
118
|
}
|
|
98
119
|
case 'tool_call': {
|
|
99
|
-
return <
|
|
120
|
+
return <ToolCard item={item} hostImage={hostImage} />
|
|
100
121
|
}
|
|
101
122
|
case 'turn_result': {
|
|
102
123
|
return <TurnResultRow item={item} />
|
|
@@ -119,6 +140,29 @@ export function TranscriptItemView({
|
|
|
119
140
|
}
|
|
120
141
|
}
|
|
121
142
|
|
|
143
|
+
function ToolCard({
|
|
144
|
+
item,
|
|
145
|
+
hostImage,
|
|
146
|
+
}: {
|
|
147
|
+
item: Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
148
|
+
hostImage?: (path: string) => Promise<string | undefined>
|
|
149
|
+
}) {
|
|
150
|
+
const command = (item.input as { command?: unknown } | null)?.command
|
|
151
|
+
const copyable = typeof command === 'string' ? command : (item.result?.text ?? '')
|
|
152
|
+
return (
|
|
153
|
+
<WithActions
|
|
154
|
+
actions={
|
|
155
|
+
<>
|
|
156
|
+
<BookmarkAction id={item.id} />
|
|
157
|
+
{copyable ? <CopyAction text={copyable} label="Copy" /> : null}
|
|
158
|
+
</>
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<ToolCallCard item={item} hostImage={hostImage} />
|
|
162
|
+
</WithActions>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
function ShellCard({ item }: { item: ShellItem }) {
|
|
123
167
|
const [open, setOpen] = useState(false)
|
|
124
168
|
const actions = useShellActions()
|
|
@@ -136,53 +180,50 @@ function ShellCard({ item }: { item: ShellItem }) {
|
|
|
136
180
|
}, [running, shellId, verify])
|
|
137
181
|
|
|
138
182
|
return (
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
type="button"
|
|
160
|
-
aria-label="Open terminal"
|
|
161
|
-
title="Open terminal"
|
|
162
|
-
className="shrink-0 text-label text-fg-3 hover:text-fg-1"
|
|
163
|
-
onClick={() => actions.open?.(shellId)}
|
|
164
|
-
>
|
|
165
|
-
⤢
|
|
166
|
-
</button>
|
|
167
|
-
) : null}
|
|
168
|
-
{running ? (
|
|
183
|
+
<WithActions
|
|
184
|
+
actions={
|
|
185
|
+
<>
|
|
186
|
+
{actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
|
|
187
|
+
{actions.agentWrite && shellGrantable(item.shell) ? (
|
|
188
|
+
<AgentWriteAction
|
|
189
|
+
granted={item.shell.agentWrite === true}
|
|
190
|
+
label={shellAgentWriteLabel(item.shell)}
|
|
191
|
+
onToggle={() => void actions.agentWrite?.(shellId, item.shell.agentWrite !== true)}
|
|
192
|
+
/>
|
|
193
|
+
) : null}
|
|
194
|
+
{running ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
|
|
195
|
+
<BookmarkAction id={item.id} />
|
|
196
|
+
<CopyAction text={item.shell.command} label="Copy command" />
|
|
197
|
+
</>
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
<div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
|
|
201
|
+
<div className="flex items-center gap-2 px-3 py-1.5">
|
|
202
|
+
<span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
|
|
169
203
|
<button
|
|
170
204
|
type="button"
|
|
171
|
-
aria-
|
|
172
|
-
className="
|
|
173
|
-
onClick={() =>
|
|
205
|
+
aria-expanded={open}
|
|
206
|
+
className="min-w-0 flex-1 truncate text-left font-mono text-label text-fg-1"
|
|
207
|
+
onClick={() => {
|
|
208
|
+
const next = !open
|
|
209
|
+
setOpen(next)
|
|
210
|
+
if (next && item.truncated && item.expanded === undefined && !item.missing) {
|
|
211
|
+
void actions.loadOutput(shellId)
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
174
214
|
>
|
|
175
|
-
|
|
215
|
+
{shellLabel(item)}
|
|
176
216
|
</button>
|
|
217
|
+
<span className={cn('shrink-0 text-label', failed ? 'text-danger' : 'text-fg-4')}>{shellStatusText(item)}</span>
|
|
218
|
+
</div>
|
|
219
|
+
{lines.length > 0 ? (
|
|
220
|
+
<pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
|
|
221
|
+
{lines.join('\n')}
|
|
222
|
+
</pre>
|
|
177
223
|
) : null}
|
|
224
|
+
{footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
|
|
178
225
|
</div>
|
|
179
|
-
|
|
180
|
-
<pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
|
|
181
|
-
{lines.join('\n')}
|
|
182
|
-
</pre>
|
|
183
|
-
) : null}
|
|
184
|
-
{footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
|
|
185
|
-
</div>
|
|
226
|
+
</WithActions>
|
|
186
227
|
)
|
|
187
228
|
}
|
|
188
229
|
|
|
@@ -233,7 +274,9 @@ function SentAttachments({
|
|
|
233
274
|
{attachments.map((attachment) => {
|
|
234
275
|
const href = attachmentUrl?.(attachment.id)
|
|
235
276
|
return attachment.mediaType.startsWith('image/') && href ? (
|
|
236
|
-
<
|
|
277
|
+
<ViewableImage key={attachment.id} image={{ src: href, name: attachment.name }} className="rounded-md">
|
|
278
|
+
<img src={href} alt={attachment.name} className="size-20 rounded-md border border-border object-cover" />
|
|
279
|
+
</ViewableImage>
|
|
237
280
|
) : (
|
|
238
281
|
<span key={attachment.id} className="rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3">
|
|
239
282
|
{attachment.name}
|
|
@@ -5,7 +5,7 @@ import { useStickToBottomContext } from 'use-stick-to-bottom'
|
|
|
5
5
|
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
6
6
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
7
7
|
import { cn } from '../../lib/utils.ts'
|
|
8
|
-
import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
|
|
8
|
+
import { ActionPlacementProvider, resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
|
|
9
9
|
import { RunRow } from '../terminal/items.tsx'
|
|
10
10
|
import { parentOf } from '../terminal/blocks.ts'
|
|
11
11
|
import { briefPx, estimateBlockPx } from '../terminal/height.ts'
|
|
@@ -324,7 +324,8 @@ export function TranscriptRows({
|
|
|
324
324
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
325
325
|
const row = rows[virtualRow.index]
|
|
326
326
|
const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
|
|
327
|
-
const
|
|
327
|
+
const flushBelow = terminal && virtualRow.index < rows.length - 1 && !gapBefore(rows, virtualRow.index + 1)
|
|
328
|
+
const view =
|
|
328
329
|
'run' in row ? (
|
|
329
330
|
<div className={cn(read(boundary, row.index) && 'opacity-45')}>
|
|
330
331
|
<RunRow items={row.run} />
|
|
@@ -348,6 +349,7 @@ export function TranscriptRows({
|
|
|
348
349
|
<TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
|
|
349
350
|
</div>
|
|
350
351
|
)
|
|
352
|
+
const content = <ActionPlacementProvider value={flushBelow ? 'inline' : 'below'}>{view}</ActionPlacementProvider>
|
|
351
353
|
if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
|
|
352
354
|
const next = promptRows.find((index) => index > virtualRow.index)
|
|
353
355
|
const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
|
|
@@ -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,91 @@
|
|
|
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
|
+
type HastNode = { type: string; tagName?: string; properties?: Record<string, unknown>; children?: HastNode[] }
|
|
15
|
+
|
|
16
|
+
function shieldLocalImages() {
|
|
17
|
+
return (tree: HastNode) => {
|
|
18
|
+
walk(tree)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walk(node: HastNode): void {
|
|
23
|
+
if (node.type === 'element' && node.tagName === 'img' && node.properties) {
|
|
24
|
+
const src = node.properties.src
|
|
25
|
+
if (typeof src === 'string' && src.trim() && !WEB_SOURCE.test(src.trim())) {
|
|
26
|
+
node.properties.src = LOCAL_PREFIX + encodeURIComponent(src.trim())
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const child of node.children ?? []) {
|
|
30
|
+
walk(child)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const MARKDOWN_REHYPE_PLUGINS: NonNullable<StreamdownProps['rehypePlugins']> = [
|
|
35
|
+
shieldLocalImages,
|
|
36
|
+
...Object.values(defaultRehypePlugins),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export function localImagePath(src: string | undefined): string | undefined {
|
|
40
|
+
if (!src?.startsWith(LOCAL_PREFIX)) {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(src.slice(LOCAL_PREFIX.length))
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function MarkdownImage({ src, alt, terminal }: { src?: unknown; alt?: unknown; terminal?: boolean }) {
|
|
51
|
+
const links = useFileLinks()
|
|
52
|
+
const raw = typeof src === 'string' ? src : undefined
|
|
53
|
+
const local = localImagePath(raw)
|
|
54
|
+
const path = local === undefined ? undefined : parseFileLink(local, links?.cwd)?.path
|
|
55
|
+
const host = useHostImageSrc(path)
|
|
56
|
+
const resolved = local === undefined ? raw : host.src
|
|
57
|
+
const label = typeof alt === 'string' && alt ? alt : undefined
|
|
58
|
+
const name = path ? baseName(path) : (label ?? 'image')
|
|
59
|
+
const failed = local !== undefined && (path === undefined || host.failed)
|
|
60
|
+
|
|
61
|
+
if (terminal) {
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
className="term-image"
|
|
65
|
+
data-state={resolved ? 'loaded' : failed ? 'failed' : 'pending'}
|
|
66
|
+
style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
|
|
67
|
+
>
|
|
68
|
+
{resolved ? (
|
|
69
|
+
<ViewableImage image={{ src: resolved, name }}>
|
|
70
|
+
<img src={resolved} alt={label ?? name} />
|
|
71
|
+
</ViewableImage>
|
|
72
|
+
) : (
|
|
73
|
+
<span data-tone="faint">{failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}</span>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!resolved) {
|
|
80
|
+
return (
|
|
81
|
+
<span className="my-1 inline-block rounded-md border border-border bg-surface-hover px-2 py-1 text-label text-fg-4">
|
|
82
|
+
{failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}
|
|
83
|
+
</span>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
<ViewableImage image={{ src: resolved, name }} className="my-1 block max-w-full">
|
|
88
|
+
<img src={resolved} alt={label ?? name} className="max-h-96 w-auto max-w-full rounded-md border border-border" />
|
|
89
|
+
</ViewableImage>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -6,6 +6,8 @@ export type ShellActions = {
|
|
|
6
6
|
kill: (shellId: string) => Promise<boolean>
|
|
7
7
|
// Drill in to the shell's terminal. Absent when the host has no frame to open one in.
|
|
8
8
|
open?: (shellId: string) => void
|
|
9
|
+
// Grant or revoke the agent's hand on a shell the user started. Absent where the agent holds no write tools.
|
|
10
|
+
agentWrite?: (shellId: string, enabled: boolean) => Promise<boolean>
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const NOOP: ShellActions = {
|