@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.
Files changed (55) hide show
  1. package/build/{SessionPanel-C1bU4yTM.d.mts → SessionPanel-BNNc8grK.d.mts} +9 -7
  2. package/build/index.d.mts +153 -45
  3. package/build/index.mjs +501 -226
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +15 -6
  6. package/build/{use-path-links-BEWxt0ZM.mjs → use-path-links-C6Fh5zdx.mjs} +1926 -2086
  7. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  12. package/src/components/agent/Composer.tsx +25 -47
  13. package/src/components/agent/ContextDialog.tsx +75 -67
  14. package/src/components/agent/FrameStrip.tsx +74 -0
  15. package/src/components/agent/HostFilesDialog.tsx +3 -2
  16. package/src/components/agent/McpDialog.tsx +112 -78
  17. package/src/components/agent/PermissionPrompt.tsx +13 -22
  18. package/src/components/agent/QuestionPrompt.tsx +6 -41
  19. package/src/components/agent/Scrubber.tsx +6 -75
  20. package/src/components/agent/SessionBrowser.tsx +125 -214
  21. package/src/components/agent/SessionFilters.tsx +266 -0
  22. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  23. package/src/components/agent/SessionItem.tsx +27 -7
  24. package/src/components/agent/SessionPanel.tsx +244 -488
  25. package/src/components/agent/SessionSteps.tsx +54 -11
  26. package/src/components/agent/ShellStrip.tsx +27 -43
  27. package/src/components/agent/SubagentStrip.tsx +13 -30
  28. package/src/components/agent/Transcript.tsx +13 -0
  29. package/src/components/agent/TranscriptItemView.tsx +6 -36
  30. package/src/components/agent/composer-commands.ts +80 -1
  31. package/src/components/agent/markdown-image.tsx +49 -1
  32. package/src/components/agent/scrubber-marks.ts +33 -11
  33. package/src/components/agent/scrubber-peek.tsx +122 -0
  34. package/src/components/agent/session-panel-providers.tsx +41 -0
  35. package/src/components/agent/shell-actions.tsx +36 -1
  36. package/src/components/agent/use-catch-up.ts +20 -0
  37. package/src/components/agent/use-frame.ts +88 -0
  38. package/src/components/agent/use-host-image.ts +81 -0
  39. package/src/components/agent/use-shell-frame.ts +12 -70
  40. package/src/components/agent/use-subagent-frame.ts +19 -75
  41. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  42. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  43. package/src/components/terminal/PermissionPrompt.tsx +19 -32
  44. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  45. package/src/components/terminal/TerminalTranscript.tsx +3 -86
  46. package/src/components/terminal/items.tsx +5 -43
  47. package/src/components/terminal/scrubber.tsx +25 -272
  48. package/src/components/ui/Popover.tsx +29 -0
  49. package/src/components/ui/Select.tsx +25 -0
  50. package/src/index.ts +46 -8
  51. package/src/lib/file-link.ts +3 -16
  52. package/src/lib/permission-prompt.ts +57 -0
  53. package/src/lib/question-answers.ts +65 -0
  54. package/build/use-path-links-BEWxt0ZM.mjs.map +0 -1
  55. 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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: setShellId, leaveShell, returnReveal, shell, label }
52
+ return { shellId, enterShell: frame.enter, leaveShell: frame.leave, returnReveal: frame.returnReveal, shell, label }
111
53
  }
@@ -1,91 +1,27 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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 [subagentId, setSubagentId] = useState<string | undefined>(undefined)
25
- const [returnReveal, setReturnReveal] = useState<{ toolUseId: string; nonce: number } | undefined>(undefined)
26
- useEffect(() => {
27
- setSubagentId(undefined)
28
- setReturnReveal(undefined)
29
- }, [sessionId])
30
-
31
- // Leaving a frame reveals the Task row it came from, so the reader lands where they left.
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 { subagentId, enterSubagent: setSubagentId, leaveSubagent, returnReveal, frameItems, task, fallbackLabel }
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
  }