@workerdeck/ui 3.0.1 → 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/build/{SessionPanel-C1bU4yTM.d.mts → SessionPanel-BNNc8grK.d.mts} +9 -7
- package/build/index.d.mts +153 -45
- package/build/index.mjs +501 -226
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -6
- package/build/{use-path-links-BEWxt0ZM.mjs → use-path-links-C6Fh5zdx.mjs} +1926 -2086
- package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +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 +13 -22
- package/src/components/agent/QuestionPrompt.tsx +6 -41
- package/src/components/agent/Scrubber.tsx +6 -75
- package/src/components/agent/SessionBrowser.tsx +125 -214
- package/src/components/agent/SessionFilters.tsx +266 -0
- package/src/components/agent/SessionInfoDialog.tsx +47 -63
- package/src/components/agent/SessionItem.tsx +27 -7
- package/src/components/agent/SessionPanel.tsx +244 -488
- package/src/components/agent/SessionSteps.tsx +54 -11
- package/src/components/agent/ShellStrip.tsx +27 -43
- package/src/components/agent/SubagentStrip.tsx +13 -30
- package/src/components/agent/Transcript.tsx +13 -0
- package/src/components/agent/TranscriptItemView.tsx +6 -36
- package/src/components/agent/composer-commands.ts +80 -1
- package/src/components/agent/markdown-image.tsx +49 -1
- 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 +36 -1
- 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/terminal/PermissionPrompt.tsx +19 -32
- package/src/components/terminal/QuestionPrompt.tsx +10 -46
- package/src/components/terminal/TerminalTranscript.tsx +3 -86
- package/src/components/terminal/items.tsx +5 -43
- package/src/components/terminal/scrubber.tsx +25 -272
- package/src/components/ui/Popover.tsx +29 -0
- package/src/components/ui/Select.tsx +25 -0
- package/src/index.ts +46 -8
- 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/build/use-path-links-BEWxt0ZM.mjs.map +0 -1
- package/src/components/prompt-area/use-markdown-mode.ts +0 -103
|
@@ -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
|
+
}
|
|
@@ -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>
|
|
@@ -25,3 +28,35 @@ export function ShellActionsProvider({ value, children }: { value: ShellActions
|
|
|
25
28
|
export function useShellActions(): ShellActions {
|
|
26
29
|
return useContext(ShellActionsContext)
|
|
27
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
|
+
}
|
|
@@ -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
|
}
|