@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.
Files changed (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. 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, Record<TranscriptDensity, { className?: string; px: number }>> = {
30
- terminal: {
31
- comfortable: { className: 'term-row-gap', px: 18 },
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 { 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
  }
@@ -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
- for (const seg of segments) {
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
- // Cursor should be placed after the chip + trailing space.
271
- // Find the *last* matching chip so duplicates resolve correctly.
272
- let lastChipEndOffset = -1
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 (chipEnd <= start || chipStart >= end) {
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