@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,41 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { FileLinkProvider, type FileLinkHandle } from '../terminal/file-link.tsx'
|
|
3
|
+
import { ShellActionsProvider, type ShellActions } from './shell-actions.tsx'
|
|
4
|
+
import { ToolResultFetchProvider, type ToolResultFetcher } from './tool-result-fetch.tsx'
|
|
5
|
+
import { ToolResultImageProvider, type ToolResultImageLoader } from './tool-result-image.tsx'
|
|
6
|
+
import { ToolTitleProvider } from './tool-titles.tsx'
|
|
7
|
+
import { TranscriptVariantProvider, type TranscriptVariant } from './transcript-variant.tsx'
|
|
8
|
+
|
|
9
|
+
type SessionPanelProvidersProps = {
|
|
10
|
+
variant: TranscriptVariant
|
|
11
|
+
fileLinks: FileLinkHandle | undefined
|
|
12
|
+
loadFullResult: ToolResultFetcher
|
|
13
|
+
shellActions: ShellActions
|
|
14
|
+
toolTitles: Record<string, string> | undefined
|
|
15
|
+
resultImages: ToolResultImageLoader
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SessionPanelProviders({
|
|
20
|
+
variant,
|
|
21
|
+
fileLinks,
|
|
22
|
+
loadFullResult,
|
|
23
|
+
shellActions,
|
|
24
|
+
toolTitles,
|
|
25
|
+
resultImages,
|
|
26
|
+
children,
|
|
27
|
+
}: SessionPanelProvidersProps) {
|
|
28
|
+
return (
|
|
29
|
+
<TranscriptVariantProvider value={variant}>
|
|
30
|
+
<FileLinkProvider value={fileLinks}>
|
|
31
|
+
<ToolResultFetchProvider value={loadFullResult}>
|
|
32
|
+
<ShellActionsProvider value={shellActions}>
|
|
33
|
+
<ToolTitleProvider value={toolTitles}>
|
|
34
|
+
<ToolResultImageProvider value={resultImages}>{children}</ToolResultImageProvider>
|
|
35
|
+
</ToolTitleProvider>
|
|
36
|
+
</ShellActionsProvider>
|
|
37
|
+
</ToolResultFetchProvider>
|
|
38
|
+
</FileLinkProvider>
|
|
39
|
+
</TranscriptVariantProvider>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from 'react'
|
|
1
|
+
import { createContext, useContext, useEffect, type ReactNode } from 'react'
|
|
2
|
+
import type { ShellItem } from '@workerdeck/react'
|
|
3
|
+
import { AgentWriteAction, BookmarkAction, CopyAction, KillShellAction, OpenShellAction } from '../terminal/affordances.tsx'
|
|
4
|
+
import { shellAgentWriteLabel, shellGrantable } from '../terminal/shell-row.ts'
|
|
2
5
|
|
|
3
6
|
export type ShellActions = {
|
|
4
7
|
loadOutput: (shellId: string) => Promise<boolean>
|
|
@@ -6,6 +9,8 @@ export type ShellActions = {
|
|
|
6
9
|
kill: (shellId: string) => Promise<boolean>
|
|
7
10
|
// Drill in to the shell's terminal. Absent when the host has no frame to open one in.
|
|
8
11
|
open?: (shellId: string) => void
|
|
12
|
+
// Grant or revoke the agent's hand on a shell the user started. Absent where the agent holds no write tools.
|
|
13
|
+
agentWrite?: (shellId: string, enabled: boolean) => Promise<boolean>
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const NOOP: ShellActions = {
|
|
@@ -23,3 +28,35 @@ export function ShellActionsProvider({ value, children }: { value: ShellActions
|
|
|
23
28
|
export function useShellActions(): ShellActions {
|
|
24
29
|
return useContext(ShellActionsContext)
|
|
25
30
|
}
|
|
31
|
+
|
|
32
|
+
// A row that still says running re-asks the gateway once, so a shell that died while nobody watched stops claiming it runs.
|
|
33
|
+
export function useVerifyRunning(item: ShellItem): void {
|
|
34
|
+
const verify = useShellActions().verify
|
|
35
|
+
const running = item.shell.status === 'running'
|
|
36
|
+
const shellId = item.shell.id
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (running) {
|
|
39
|
+
void verify(shellId)
|
|
40
|
+
}
|
|
41
|
+
}, [running, shellId, verify])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ShellItemActions({ item }: { item: ShellItem }) {
|
|
45
|
+
const actions = useShellActions()
|
|
46
|
+
const shellId = item.shell.id
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
|
|
50
|
+
{actions.agentWrite && shellGrantable(item.shell) ? (
|
|
51
|
+
<AgentWriteAction
|
|
52
|
+
granted={item.shell.agentWrite === true}
|
|
53
|
+
label={shellAgentWriteLabel(item.shell)}
|
|
54
|
+
onToggle={() => void actions.agentWrite?.(shellId, item.shell.agentWrite !== true)}
|
|
55
|
+
/>
|
|
56
|
+
) : null}
|
|
57
|
+
{item.shell.status === 'running' ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
|
|
58
|
+
<BookmarkAction id={item.id} />
|
|
59
|
+
<CopyAction text={item.shell.command} label="Copy command" />
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -55,6 +55,46 @@ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageS
|
|
|
55
55
|
return state
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type HostImageLoader = (path: string) => Promise<string | undefined>
|
|
59
|
+
|
|
60
|
+
const HostImageContext = createContext<HostImageLoader | undefined>(undefined)
|
|
61
|
+
|
|
62
|
+
export function HostImageProvider({ value, children }: { value: HostImageLoader | undefined; children: ReactNode }) {
|
|
63
|
+
return <HostImageContext.Provider value={value}>{children}</HostImageContext.Provider>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useHostImageSrc(path: string | undefined, override?: HostImageLoader): ToolResultImageState {
|
|
67
|
+
const provided = useContext(HostImageContext)
|
|
68
|
+
const load = override ?? provided
|
|
69
|
+
const [state, setState] = useState<ToolResultImageState>({ failed: false })
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (path === undefined) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (!load) {
|
|
75
|
+
setState({ failed: true })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
let live = true
|
|
79
|
+
setState({ failed: false })
|
|
80
|
+
load(path)
|
|
81
|
+
.then((src) => {
|
|
82
|
+
if (live) {
|
|
83
|
+
setState({ src, failed: src === undefined })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {
|
|
87
|
+
if (live) {
|
|
88
|
+
setState({ failed: true })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return () => {
|
|
92
|
+
live = false
|
|
93
|
+
}
|
|
94
|
+
}, [load, path])
|
|
95
|
+
return state
|
|
96
|
+
}
|
|
97
|
+
|
|
58
98
|
const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
|
|
59
99
|
|
|
60
100
|
type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
|
|
@@ -12,27 +12,9 @@ export function useTranscriptVariant(): TranscriptVariant {
|
|
|
12
12
|
return useContext(VariantContext)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
16
|
-
|
|
17
|
-
const DensityContext = createContext<TranscriptDensity>('comfortable')
|
|
18
|
-
|
|
19
|
-
export function TranscriptDensityProvider({ value, children }: { value: TranscriptDensity; children: ReactNode }) {
|
|
20
|
-
return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function useTranscriptDensity(): TranscriptDensity {
|
|
24
|
-
return useContext(DensityContext)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
export type TranscriptFont = 'sans' | 'mono'
|
|
28
16
|
|
|
29
|
-
export const ROW_GAP: Record<TranscriptVariant,
|
|
30
|
-
terminal: {
|
|
31
|
-
|
|
32
|
-
compact: { className: 'term-row-gap', px: 18 },
|
|
33
|
-
},
|
|
34
|
-
cards: {
|
|
35
|
-
comfortable: { className: 'pt-4', px: 16 },
|
|
36
|
-
compact: { className: 'pt-2', px: 8 },
|
|
37
|
-
},
|
|
17
|
+
export const ROW_GAP: Record<TranscriptVariant, { className?: string; px: number }> = {
|
|
18
|
+
terminal: { className: 'term-row-gap', px: 18 },
|
|
19
|
+
cards: { className: 'pt-5', px: 20 },
|
|
38
20
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export type CatchUpMark = { itemCount: number; since?: number }
|
|
4
|
+
|
|
5
|
+
type CatchUpState = { sessionId: string | undefined; mark: CatchUpMark | undefined; caughtUp: boolean }
|
|
6
|
+
|
|
7
|
+
// The mark is the host's `unseen` as it stood when this session opened; later values are the host catching up, not news.
|
|
8
|
+
export function useCatchUp(
|
|
9
|
+
sessionId: string | undefined,
|
|
10
|
+
unseen: CatchUpMark | undefined,
|
|
11
|
+
): { mark: CatchUpMark | undefined; dismiss: () => void } {
|
|
12
|
+
const [held, setHeld] = useState<CatchUpState>({ sessionId, mark: unseen, caughtUp: false })
|
|
13
|
+
let current = held
|
|
14
|
+
if (held.sessionId !== sessionId) {
|
|
15
|
+
current = { sessionId, mark: unseen, caughtUp: false }
|
|
16
|
+
setHeld(current)
|
|
17
|
+
}
|
|
18
|
+
const dismiss = useCallback(() => setHeld((prev) => (prev.caughtUp ? prev : { ...prev, caughtUp: true })), [])
|
|
19
|
+
return { mark: current.caughtUp ? undefined : current.mark, dismiss }
|
|
20
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FrameReveal = { toolUseId: string; nonce: number }
|
|
4
|
+
|
|
5
|
+
export type FrameOptions = {
|
|
6
|
+
sessionId: string | undefined
|
|
7
|
+
// A host request to enter a frame; `id: undefined` withdraws it.
|
|
8
|
+
request?: { id: string | undefined; nonce: number }
|
|
9
|
+
// A host request to reveal a transcript row: it closes the frame without the return reveal, which would fight it.
|
|
10
|
+
reveal?: { nonce: number }
|
|
11
|
+
onChange?: (id: string | undefined) => void
|
|
12
|
+
// The transcript row leaving the frame reveals, so the reader lands where they left.
|
|
13
|
+
returnTo: (id: string) => string | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Frame = {
|
|
17
|
+
id: string | undefined
|
|
18
|
+
enter: (id: string) => void
|
|
19
|
+
leave: () => void
|
|
20
|
+
returnReveal: FrameReveal | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The frame machine behind both the sub-agent and the shell takeover. The frame round-trips through
|
|
24
|
+
// the host's URL (GOTCHAS "The sub-agent frame round-trips through the URL"): entry keys on the nonce
|
|
25
|
+
// alone, and the report is deduped through a ref, so an echo of our own report is inert on arrival.
|
|
26
|
+
export function useFrame({ sessionId, request, reveal, onChange, returnTo }: FrameOptions): Frame {
|
|
27
|
+
const [id, setId] = useState<string | undefined>(undefined)
|
|
28
|
+
const [returnReveal, setReturnReveal] = useState<FrameReveal | undefined>(undefined)
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setId(undefined)
|
|
31
|
+
setReturnReveal(undefined)
|
|
32
|
+
}, [sessionId])
|
|
33
|
+
|
|
34
|
+
const returnToRef = useRef(returnTo)
|
|
35
|
+
returnToRef.current = returnTo
|
|
36
|
+
const leave = useCallback(() => {
|
|
37
|
+
setId((current) => {
|
|
38
|
+
const toolUseId = current === undefined ? undefined : returnToRef.current(current)
|
|
39
|
+
if (toolUseId !== undefined) {
|
|
40
|
+
setReturnReveal({ toolUseId, nonce: Date.now() })
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
})
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
const requestNonce = request?.nonce
|
|
47
|
+
const requestId = request?.id
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (requestId === undefined) {
|
|
50
|
+
leave()
|
|
51
|
+
} else {
|
|
52
|
+
setId(requestId)
|
|
53
|
+
}
|
|
54
|
+
}, [requestNonce])
|
|
55
|
+
|
|
56
|
+
const revealNonce = reveal?.nonce
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (revealNonce !== undefined) {
|
|
59
|
+
setId(undefined)
|
|
60
|
+
}
|
|
61
|
+
}, [revealNonce])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (id === undefined) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const onKey = (event: KeyboardEvent) => {
|
|
68
|
+
if (event.key === 'Escape' && !event.defaultPrevented) {
|
|
69
|
+
leave()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
window.addEventListener('keydown', onKey)
|
|
73
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
74
|
+
}, [id, leave])
|
|
75
|
+
|
|
76
|
+
const onChangeRef = useRef(onChange)
|
|
77
|
+
onChangeRef.current = onChange
|
|
78
|
+
const reported = useRef<string | undefined>(undefined)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (reported.current === id) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
reported.current = id
|
|
84
|
+
onChangeRef.current?.(id)
|
|
85
|
+
}, [id])
|
|
86
|
+
|
|
87
|
+
return { id, enter: setId, leave, returnReveal }
|
|
88
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import type { ProducedFileRef } from '@workerdeck/react'
|
|
4
|
+
|
|
5
|
+
type HostImageStore = { cache: Map<string, Promise<string | undefined>>; urls: Set<string>; live: boolean }
|
|
6
|
+
|
|
7
|
+
const IMAGE_MEDIA_TYPES: Record<string, string> = {
|
|
8
|
+
png: 'image/png',
|
|
9
|
+
jpg: 'image/jpeg',
|
|
10
|
+
jpeg: 'image/jpeg',
|
|
11
|
+
gif: 'image/gif',
|
|
12
|
+
webp: 'image/webp',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A definitive "no image" stays cached; a failed request does not, so a later render can ask again.
|
|
16
|
+
export function useHostImage(
|
|
17
|
+
client: WorkerDeckClient,
|
|
18
|
+
sessionId: string | undefined,
|
|
19
|
+
producedFiles: Record<string, ProducedFileRef> | undefined,
|
|
20
|
+
): (path: string) => Promise<string | undefined> {
|
|
21
|
+
const store = useMemo<HostImageStore>(() => ({ cache: new Map(), urls: new Set(), live: true }), [client, sessionId])
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
store.live = true
|
|
24
|
+
return () => {
|
|
25
|
+
store.live = false
|
|
26
|
+
for (const url of store.urls) {
|
|
27
|
+
URL.revokeObjectURL(url)
|
|
28
|
+
}
|
|
29
|
+
store.urls.clear()
|
|
30
|
+
store.cache.clear()
|
|
31
|
+
}
|
|
32
|
+
}, [store])
|
|
33
|
+
return useCallback(
|
|
34
|
+
(path: string) => {
|
|
35
|
+
const produced = producedFiles?.[path]
|
|
36
|
+
const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
|
|
37
|
+
const hit = store.cache.get(key)
|
|
38
|
+
if (hit) {
|
|
39
|
+
return hit
|
|
40
|
+
}
|
|
41
|
+
const request = produced && sessionId ? readProduced(client, sessionId, produced.fileId, store) : readHostImage(client, path)
|
|
42
|
+
const pending = request.catch(() => {
|
|
43
|
+
if (store.cache.get(key) === pending) {
|
|
44
|
+
store.cache.delete(key)
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
})
|
|
48
|
+
store.cache.set(key, pending)
|
|
49
|
+
return pending
|
|
50
|
+
},
|
|
51
|
+
[client, sessionId, producedFiles, store],
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readProduced(
|
|
56
|
+
client: WorkerDeckClient,
|
|
57
|
+
sessionId: string,
|
|
58
|
+
fileId: string,
|
|
59
|
+
store: HostImageStore,
|
|
60
|
+
): Promise<string | undefined> {
|
|
61
|
+
const blob = await client.readProducedFile(sessionId, fileId)
|
|
62
|
+
if (blob.size === 0) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
const url = URL.createObjectURL(blob)
|
|
66
|
+
if (!store.live) {
|
|
67
|
+
URL.revokeObjectURL(url)
|
|
68
|
+
throw new Error('host image store disposed')
|
|
69
|
+
}
|
|
70
|
+
store.urls.add(url)
|
|
71
|
+
return url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readHostImage(client: WorkerDeckClient, path: string): Promise<string | undefined> {
|
|
75
|
+
const file = await client.readHostFile(path)
|
|
76
|
+
if (file.encoding !== 'base64') {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
const mediaType = IMAGE_MEDIA_TYPES[path.slice(path.lastIndexOf('.') + 1).toLowerCase()]
|
|
80
|
+
return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
|
|
81
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, useRef } from 'react'
|
|
2
2
|
import type { SessionInfo, ShellInfo } from '@workerdeck/protocol'
|
|
3
3
|
import type { ShellItem, TranscriptItem } from '@workerdeck/react'
|
|
4
|
+
import { useFrame } from './use-frame.ts'
|
|
4
5
|
|
|
5
6
|
export type ShellFrameOptions = {
|
|
6
7
|
sessionId: string | undefined
|
|
@@ -21,83 +22,24 @@ export type ShellFrame = {
|
|
|
21
22
|
label: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
// The shell frame machine, the sub-agent frame's twin: entry keys on the nonce alone, Escape
|
|
25
|
-
// leaves, and the report is deduped through a ref so an echo of our own report is inert.
|
|
26
25
|
export function useShellFrame(options: ShellFrameOptions): ShellFrame {
|
|
27
26
|
const { sessionId, items, session, shells, reveal, openShell, onShellChange } = options
|
|
28
|
-
|
|
29
|
-
const [shellId, setShellId] = useState<string | undefined>(undefined)
|
|
30
|
-
const [returnReveal, setReturnReveal] = useState<{ toolUseId: string; nonce: number } | undefined>(undefined)
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
setShellId(undefined)
|
|
33
|
-
setReturnReveal(undefined)
|
|
34
|
-
}, [sessionId])
|
|
35
|
-
|
|
36
27
|
const rowIdRef = useRef<string | undefined>(undefined)
|
|
28
|
+
const frame = useFrame({
|
|
29
|
+
sessionId,
|
|
30
|
+
request: openShell && { id: openShell.shellId, nonce: openShell.nonce },
|
|
31
|
+
reveal,
|
|
32
|
+
onChange: onShellChange,
|
|
33
|
+
returnTo: () => rowIdRef.current,
|
|
34
|
+
})
|
|
35
|
+
const shellId = frame.id
|
|
36
|
+
|
|
37
37
|
const row = useMemo(
|
|
38
38
|
() => (shellId === undefined ? undefined : items.find((item): item is ShellItem => item.kind === 'shell' && item.shell.id === shellId)),
|
|
39
39
|
[items, shellId],
|
|
40
40
|
)
|
|
41
41
|
rowIdRef.current = row?.id
|
|
42
42
|
|
|
43
|
-
// Leaving reveals the `$` row the frame came from, so the reader lands where they left.
|
|
44
|
-
const leaveShell = useCallback(() => {
|
|
45
|
-
setShellId((current) => {
|
|
46
|
-
const rowId = rowIdRef.current
|
|
47
|
-
if (current !== undefined && rowId !== undefined) {
|
|
48
|
-
setReturnReveal({ toolUseId: rowId, nonce: Date.now() })
|
|
49
|
-
}
|
|
50
|
-
return undefined
|
|
51
|
-
})
|
|
52
|
-
}, [])
|
|
53
|
-
|
|
54
|
-
const openShellNonce = openShell?.nonce
|
|
55
|
-
const openShellId = openShell?.shellId
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (openShellId === undefined) {
|
|
58
|
-
leaveShell()
|
|
59
|
-
} else {
|
|
60
|
-
setShellId(openShellId)
|
|
61
|
-
}
|
|
62
|
-
// Keyed on the nonce alone: an unchanged nonce is the host echoing our own report, and
|
|
63
|
-
// acting on it starts the URL -> panel -> URL cycle.
|
|
64
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
-
}, [openShellNonce])
|
|
66
|
-
|
|
67
|
-
const revealNonce = reveal?.nonce
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (revealNonce === undefined) {
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
setShellId(undefined)
|
|
73
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, [revealNonce])
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (shellId === undefined) {
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
const onKey = (event: KeyboardEvent) => {
|
|
81
|
-
if (event.key !== 'Escape' || event.defaultPrevented) {
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
leaveShell()
|
|
85
|
-
}
|
|
86
|
-
window.addEventListener('keydown', onKey)
|
|
87
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
88
|
-
}, [shellId, leaveShell])
|
|
89
|
-
|
|
90
|
-
const onShellChangeRef = useRef(onShellChange)
|
|
91
|
-
onShellChangeRef.current = onShellChange
|
|
92
|
-
const reportedShellId = useRef<string | undefined>(undefined)
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (reportedShellId.current === shellId) {
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
reportedShellId.current = shellId
|
|
98
|
-
onShellChangeRef.current?.(shellId)
|
|
99
|
-
}, [shellId])
|
|
100
|
-
|
|
101
43
|
const shell = useMemo(() => {
|
|
102
44
|
if (shellId === undefined) {
|
|
103
45
|
return undefined
|
|
@@ -107,5 +49,5 @@ export function useShellFrame(options: ShellFrameOptions): ShellFrame {
|
|
|
107
49
|
|
|
108
50
|
const label = shell ? shell.label || shell.command.split('\n')[0] || 'shell' : 'shell'
|
|
109
51
|
|
|
110
|
-
return { shellId, enterShell:
|
|
52
|
+
return { shellId, enterShell: frame.enter, leaveShell: frame.leave, returnReveal: frame.returnReveal, shell, label }
|
|
111
53
|
}
|
|
@@ -1,91 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
2
|
import { subagentLabel, type SessionInfo } from '@workerdeck/protocol'
|
|
3
3
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
4
4
|
import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
|
|
5
|
+
import { useFrame } from './use-frame.ts'
|
|
5
6
|
|
|
6
|
-
// The sub-agent frame machine: which agent frame is on screen, how it is entered (a host
|
|
7
|
-
// `openSubagent` request or a Task row in the transcript), how it is left (Escape, the strip's
|
|
8
|
-
// Back, or the host withdrawing), and what the transcript reveals on the way out. The frame
|
|
9
|
-
// round-trips through the host's URL - the anti-loop rules live in GOTCHAS ("The sub-agent
|
|
10
|
-
// frame round-trips through the URL"); here they mean: entry keys on the nonce alone, and the
|
|
11
|
-
// report is deduped through a ref, so an echo of our own report is inert on arrival.
|
|
12
7
|
export function useSubagentFrame(options: {
|
|
13
8
|
sessionId: string | undefined
|
|
14
9
|
items: TranscriptItem[]
|
|
15
10
|
session: SessionInfo | undefined
|
|
16
|
-
// Host request to reveal a transcript row; entering a frame must not eat a pending one.
|
|
17
11
|
reveal?: { toolUseId: string; nonce: number }
|
|
18
|
-
// Host request to enter a frame; `toolUseId: undefined` withdraws it (Back/Forward).
|
|
19
12
|
openSubagent?: { toolUseId: string; nonce: number }
|
|
20
13
|
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
21
14
|
}) {
|
|
22
15
|
const { sessionId, items, session, reveal, openSubagent, onSubagentChange } = options
|
|
23
16
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const leaveSubagent = useCallback(() => {
|
|
33
|
-
setSubagentId((current) => {
|
|
34
|
-
if (current !== undefined) {
|
|
35
|
-
setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
36
|
-
}
|
|
37
|
-
return undefined
|
|
38
|
-
})
|
|
39
|
-
}, [])
|
|
40
|
-
|
|
41
|
-
const openSubagentNonce = openSubagent?.nonce
|
|
42
|
-
const openSubagentId = openSubagent?.toolUseId
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
if (openSubagentId === undefined) {
|
|
45
|
-
leaveSubagent()
|
|
46
|
-
} else {
|
|
47
|
-
setSubagentId(openSubagentId)
|
|
48
|
-
}
|
|
49
|
-
// Keyed on the nonce alone: an unchanged nonce is the host echoing our own report, and
|
|
50
|
-
// acting on it starts the URL → panel → URL cycle.
|
|
51
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
-
}, [openSubagentNonce])
|
|
53
|
-
|
|
54
|
-
// A reveal targets the root transcript, so it closes whatever frame is open - without the
|
|
55
|
-
// return reveal, which would fight the requested one.
|
|
56
|
-
const revealNonce = reveal?.nonce
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
if (revealNonce === undefined) {
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
setSubagentId(undefined)
|
|
62
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
-
}, [revealNonce])
|
|
64
|
-
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (subagentId === undefined) {
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
const onKey = (event: KeyboardEvent) => {
|
|
70
|
-
if (event.key !== 'Escape' || event.defaultPrevented) {
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
leaveSubagent()
|
|
74
|
-
}
|
|
75
|
-
window.addEventListener('keydown', onKey)
|
|
76
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
77
|
-
}, [subagentId, leaveSubagent])
|
|
78
|
-
|
|
79
|
-
const onSubagentChangeRef = useRef(onSubagentChange)
|
|
80
|
-
onSubagentChangeRef.current = onSubagentChange
|
|
81
|
-
const reportedSubagentId = useRef<string | undefined>(undefined)
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (reportedSubagentId.current === subagentId) {
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
reportedSubagentId.current = subagentId
|
|
87
|
-
onSubagentChangeRef.current?.(subagentId)
|
|
88
|
-
}, [subagentId])
|
|
17
|
+
const frame = useFrame({
|
|
18
|
+
sessionId,
|
|
19
|
+
request: openSubagent && { id: openSubagent.toolUseId, nonce: openSubagent.nonce },
|
|
20
|
+
reveal,
|
|
21
|
+
onChange: onSubagentChange,
|
|
22
|
+
returnTo: (toolUseId) => toolUseId,
|
|
23
|
+
})
|
|
24
|
+
const subagentId = frame.id
|
|
89
25
|
|
|
90
26
|
const frameItems = useMemo(() => (subagentId === undefined ? [] : subagentItems(items, subagentId)), [items, subagentId])
|
|
91
27
|
const task = useMemo(
|
|
@@ -100,5 +36,13 @@ export function useSubagentFrame(options: {
|
|
|
100
36
|
return record ? subagentLabel(record) : 'Sub-agent'
|
|
101
37
|
}, [session, subagentId])
|
|
102
38
|
|
|
103
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
subagentId,
|
|
41
|
+
enterSubagent: frame.enter,
|
|
42
|
+
leaveSubagent: frame.leave,
|
|
43
|
+
returnReveal: frame.returnReveal,
|
|
44
|
+
frameItems,
|
|
45
|
+
task,
|
|
46
|
+
fallbackLabel,
|
|
47
|
+
}
|
|
104
48
|
}
|
|
@@ -210,8 +210,10 @@ export function resolveChip(
|
|
|
210
210
|
|
|
211
211
|
const newSegments: Segment[] = []
|
|
212
212
|
let offset = 0
|
|
213
|
+
let insertedChip: ChipSegment | undefined
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
// Merged first: the trigger range is typed text, so it then lies in exactly one text segment.
|
|
216
|
+
for (const seg of mergeAdjacentTextSegments(segments)) {
|
|
215
217
|
if (seg.type === 'chip') {
|
|
216
218
|
const chipText = chipPlainText(seg)
|
|
217
219
|
const chipStart = offset
|
|
@@ -252,6 +254,7 @@ export function resolveChip(
|
|
|
252
254
|
...(chip.autoResolved ? { autoResolved: true } : {}),
|
|
253
255
|
}
|
|
254
256
|
newSegments.push(newChip)
|
|
257
|
+
insertedChip = newChip
|
|
255
258
|
|
|
256
259
|
// Add the trailing text (a space by default) after the chip, then any remaining text
|
|
257
260
|
if (afterText) {
|
|
@@ -266,23 +269,10 @@ export function resolveChip(
|
|
|
266
269
|
}
|
|
267
270
|
|
|
268
271
|
const merged = mergeAdjacentTextSegments(newSegments)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
let runningOffset = 0
|
|
274
|
-
for (const seg of merged) {
|
|
275
|
-
if (seg.type === 'text') {
|
|
276
|
-
runningOffset += seg.text.length
|
|
277
|
-
} else {
|
|
278
|
-
runningOffset += chipPlainText(seg).length
|
|
279
|
-
if (seg.value === chip.value && seg.displayText === chip.displayText && seg.trigger === activeTrigger.config.char) {
|
|
280
|
-
lastChipEndOffset = runningOffset
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
// The caret lands after the chip and its trailing text
|
|
285
|
-
const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + trailingText.length
|
|
272
|
+
// Everything before the trigger is untouched, so the new chip starts exactly where the trigger did.
|
|
273
|
+
const cursorOffset = insertedChip
|
|
274
|
+
? triggerStart + chipPlainText(insertedChip).length + trailingText.length
|
|
275
|
+
: segmentsToPlainText(merged).length
|
|
286
276
|
|
|
287
277
|
return { segments: merged, cursorOffset }
|
|
288
278
|
}
|
|
@@ -309,7 +299,7 @@ export function resolveText(
|
|
|
309
299
|
let offset = 0
|
|
310
300
|
let cursorOffset = triggerStart + text.length
|
|
311
301
|
|
|
312
|
-
for (const seg of segments) {
|
|
302
|
+
for (const seg of mergeAdjacentTextSegments(segments)) {
|
|
313
303
|
if (seg.type === 'chip') {
|
|
314
304
|
const chipEnd = offset + chipPlainText(seg).length
|
|
315
305
|
// A trigger range can never overlap a chip - chips are atomic - so a chip
|
|
@@ -510,8 +500,18 @@ export function replaceTextRange(segments: Segment[], start: number, end: number
|
|
|
510
500
|
inserted = true
|
|
511
501
|
}
|
|
512
502
|
|
|
513
|
-
if (
|
|
503
|
+
if (start === end) {
|
|
514
504
|
newSegments.push(seg)
|
|
505
|
+
// A collapsed insertion can never split an atomic chip, so one inside it lands just after.
|
|
506
|
+
if (!inserted && chipStart < start && start < chipEnd) {
|
|
507
|
+
newSegments.push({ type: 'text', text: replacement })
|
|
508
|
+
inserted = true
|
|
509
|
+
}
|
|
510
|
+
} else if (chipEnd <= start || chipStart >= end) {
|
|
511
|
+
newSegments.push(seg)
|
|
512
|
+
} else if (!inserted) {
|
|
513
|
+
newSegments.push({ type: 'text', text: replacement })
|
|
514
|
+
inserted = true
|
|
515
515
|
}
|
|
516
516
|
offset = chipEnd
|
|
517
517
|
} else {
|
|
@@ -709,6 +709,7 @@ export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
|
|
|
709
709
|
sa.trigger !== sb.trigger ||
|
|
710
710
|
sa.value !== sb.value ||
|
|
711
711
|
sa.displayText !== sb.displayText ||
|
|
712
|
+
sa.sigil !== sb.sigil ||
|
|
712
713
|
sa.autoResolved !== sb.autoResolved
|
|
713
714
|
) {
|
|
714
715
|
return false
|