@workerdeck/ui 0.23.0 → 1.0.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-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,59 +1,39 @@
|
|
|
1
1
|
import { useEffect, type RefObject } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Cmd/Ctrl+click a file path in the transcript to open it.
|
|
5
|
-
*
|
|
6
|
-
* The agent names files constantly — in prose, in tables, in tool arguments —
|
|
7
|
-
* and every one of them is a place the reader wants to go. They already *look*
|
|
8
|
-
* like links (inline code renders in the accent), so the affordance is half
|
|
9
|
-
* promised before anything is wired: this makes it true.
|
|
10
|
-
*
|
|
11
|
-
* **Detection is deliberately conservative**, because the cost is asymmetric. A
|
|
12
|
-
* missed path is a path you select and open yourself; a false positive is a
|
|
13
|
-
* word that lights up under the modifier and then does nothing, which teaches
|
|
14
|
-
* the reader not to trust any of them. So:
|
|
15
|
-
*
|
|
16
|
-
* - A trailing `/` means a directory. Directories are not openable in a text
|
|
17
|
-
* editor, and offering it is worse than staying quiet.
|
|
18
|
-
* - Outside a `<code>` element the match must be *most* of what the element
|
|
19
|
-
* says, or a paragraph mentioning `src/index.ts` in passing would underline
|
|
20
|
-
* whole sentences.
|
|
21
|
-
* - A bare filename (`worker.mjs`) is only a path **inside code**, and only
|
|
22
|
-
* with an extension. `18 B` and `Type` are table cells, not files.
|
|
23
|
-
*
|
|
24
|
-
* The modifier is the editor's own: hold it and what would open lights up
|
|
25
|
-
* (`wd-path-link`), which is the only honest way to show which of the blue
|
|
26
|
-
* things on screen are actually reachable.
|
|
27
|
-
*/
|
|
28
3
|
export type PathHit = { path: string; line?: number }
|
|
29
4
|
|
|
30
|
-
/** Marks the element under the pointer while the modifier is held. */
|
|
31
5
|
const LINKISH = 'wd-path-link'
|
|
32
6
|
|
|
33
|
-
/** Absolute, or relative with at least one separator. `:12` is a line number. */
|
|
34
7
|
const PATH_RE = /^(\/[^\s:'"`()[\]{}]+|[\w.@~-]+(?:\/[\w.@~-]+)+)(?::(\d+))?$/
|
|
35
|
-
/** A bare filename — only trusted inside a code element. */
|
|
36
8
|
const FILE_RE = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
|
|
37
9
|
|
|
38
|
-
/** How much of an element's text the match must cover, outside code. */
|
|
39
10
|
const COVERAGE = 0.6
|
|
40
11
|
|
|
41
12
|
export function matchPath(text: string, inCode: boolean): PathHit | undefined {
|
|
42
13
|
const trimmed = text.trim()
|
|
43
|
-
if (!trimmed || trimmed.endsWith('/'))
|
|
14
|
+
if (!trimmed || trimmed.endsWith('/')) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
44
17
|
const hit = PATH_RE.exec(trimmed) ?? (inCode ? FILE_RE.exec(trimmed) : null)
|
|
45
|
-
if (!hit)
|
|
18
|
+
if (!hit) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
46
21
|
return { path: hit[1]!, line: hit[2] ? Number(hit[2]) : undefined }
|
|
47
22
|
}
|
|
48
23
|
|
|
49
|
-
/** The path a click on this element means, if any. */
|
|
50
24
|
function hitFor(element: HTMLElement | undefined): PathHit | undefined {
|
|
51
|
-
if (!element)
|
|
25
|
+
if (!element) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
52
28
|
const inCode = element.closest('code') !== null
|
|
53
29
|
const text = element.textContent ?? ''
|
|
54
30
|
const hit = matchPath(text, inCode)
|
|
55
|
-
if (!hit)
|
|
56
|
-
|
|
31
|
+
if (!hit) {
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
if (!inCode && hit.path.length < text.trim().length * COVERAGE) {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
57
37
|
return hit
|
|
58
38
|
}
|
|
59
39
|
|
|
@@ -63,22 +43,16 @@ export function usePathLinks({
|
|
|
63
43
|
enabled = true,
|
|
64
44
|
ignore,
|
|
65
45
|
}: {
|
|
66
|
-
/** The subtree to listen in — normally the panel. */
|
|
67
46
|
container: RefObject<HTMLElement | null>
|
|
68
47
|
onOpen: (hit: PathHit) => void
|
|
69
|
-
/** Off when there is no host filesystem to open from: a link that cannot
|
|
70
|
-
* resolve is worse than plain text. */
|
|
71
48
|
enabled?: boolean
|
|
72
|
-
/**
|
|
73
|
-
* A selector for subtrees to leave alone. Anything with its own meaning for
|
|
74
|
-
* the modifier needs this — a code editor's Cmd+click is go-to-definition,
|
|
75
|
-
* and every identifier in it would otherwise match as a filename.
|
|
76
|
-
*/
|
|
77
49
|
ignore?: string
|
|
78
50
|
}) {
|
|
79
51
|
useEffect(() => {
|
|
80
52
|
const root = container.current
|
|
81
|
-
if (!root || !enabled)
|
|
53
|
+
if (!root || !enabled) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
82
56
|
const excluded = (element: HTMLElement | undefined) =>
|
|
83
57
|
ignore !== undefined && element?.closest(ignore) !== null && element !== undefined
|
|
84
58
|
|
|
@@ -89,13 +63,17 @@ export function usePathLinks({
|
|
|
89
63
|
}
|
|
90
64
|
|
|
91
65
|
const onClick = (event: MouseEvent) => {
|
|
92
|
-
if (!event.metaKey && !event.ctrlKey)
|
|
66
|
+
if (!event.metaKey && !event.ctrlKey) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
93
69
|
const target = event.target instanceof HTMLElement ? event.target : undefined
|
|
94
|
-
if (excluded(target))
|
|
70
|
+
if (excluded(target)) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
95
73
|
const hit = hitFor(target)
|
|
96
|
-
if (!hit)
|
|
97
|
-
|
|
98
|
-
|
|
74
|
+
if (!hit) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
99
77
|
event.preventDefault()
|
|
100
78
|
event.stopPropagation()
|
|
101
79
|
unmark()
|
|
@@ -103,19 +81,25 @@ export function usePathLinks({
|
|
|
103
81
|
}
|
|
104
82
|
|
|
105
83
|
const onMove = (event: MouseEvent) => {
|
|
106
|
-
if (!event.metaKey && !event.ctrlKey)
|
|
84
|
+
if (!event.metaKey && !event.ctrlKey) {
|
|
85
|
+
return unmark()
|
|
86
|
+
}
|
|
107
87
|
const element = event.target instanceof HTMLElement ? event.target : undefined
|
|
108
|
-
if (element === marked)
|
|
88
|
+
if (element === marked) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
109
91
|
unmark()
|
|
110
|
-
if (!element || excluded(element) || !hitFor(element))
|
|
92
|
+
if (!element || excluded(element) || !hitFor(element)) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
111
95
|
marked = element
|
|
112
96
|
element.classList.add(LINKISH)
|
|
113
97
|
}
|
|
114
98
|
|
|
115
|
-
// Releasing the modifier has to clear it: an underline that outlives the key
|
|
116
|
-
// promises a click that will not work.
|
|
117
99
|
const onKey = (event: KeyboardEvent) => {
|
|
118
|
-
if (!event.metaKey && !event.ctrlKey)
|
|
100
|
+
if (!event.metaKey && !event.ctrlKey) {
|
|
101
|
+
unmark()
|
|
102
|
+
}
|
|
119
103
|
}
|
|
120
104
|
|
|
121
105
|
root.addEventListener('click', onClick, true)
|
|
@@ -134,14 +118,12 @@ export function usePathLinks({
|
|
|
134
118
|
}, [container, onOpen, enabled, ignore])
|
|
135
119
|
}
|
|
136
120
|
|
|
137
|
-
/**
|
|
138
|
-
* A transcript path against the session's cwd.
|
|
139
|
-
*
|
|
140
|
-
* Absolute wins outright. Everything else is relative to where the agent is
|
|
141
|
-
* working, which is the only root that makes `worker.mjs` mean anything.
|
|
142
|
-
*/
|
|
143
121
|
export function resolveAgainstCwd(path: string, cwd: string | undefined): string {
|
|
144
|
-
if (path.startsWith('/'))
|
|
145
|
-
|
|
122
|
+
if (path.startsWith('/')) {
|
|
123
|
+
return path
|
|
124
|
+
}
|
|
125
|
+
if (!cwd) {
|
|
126
|
+
return path
|
|
127
|
+
}
|
|
146
128
|
return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
|
|
147
129
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { subagentLabel, type SessionInfo } from '@workerdeck/protocol'
|
|
3
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
4
|
+
import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sub-agent frame machine: which agent frame is on screen, how it is entered (a host
|
|
8
|
+
* `openSubagent` request or a Task row in the transcript), how it is left (Escape, the strip's
|
|
9
|
+
* Back, or the host withdrawing), and what the transcript reveals on the way out. The frame
|
|
10
|
+
* round-trips through the host's URL — the anti-loop rules live in GOTCHAS ("The sub-agent
|
|
11
|
+
* frame round-trips through the URL"); here they mean: entry keys on the nonce alone, and the
|
|
12
|
+
* report is deduped through a ref, so an echo of our own report is inert on arrival.
|
|
13
|
+
*/
|
|
14
|
+
export function useSubagentFrame(options: {
|
|
15
|
+
sessionId: string | undefined
|
|
16
|
+
items: TranscriptItem[]
|
|
17
|
+
session: SessionInfo | undefined
|
|
18
|
+
/** Host request to reveal a transcript row; entering a frame must not eat a pending one. */
|
|
19
|
+
reveal?: { toolUseId: string; nonce: number }
|
|
20
|
+
/** Host request to enter a frame; `toolUseId: undefined` withdraws it (Back/Forward). */
|
|
21
|
+
openSubagent?: { toolUseId: string; nonce: number }
|
|
22
|
+
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
23
|
+
}) {
|
|
24
|
+
const { sessionId, items, session, reveal, openSubagent, onSubagentChange } = options
|
|
25
|
+
|
|
26
|
+
const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
|
|
27
|
+
const [returnReveal, setReturnReveal] = useState<{ toolUseId: string; nonce: number } | undefined>(undefined)
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setSubagentId(undefined)
|
|
30
|
+
setReturnReveal(undefined)
|
|
31
|
+
}, [sessionId])
|
|
32
|
+
|
|
33
|
+
// Leaving a frame reveals the Task row it came from, so the reader lands where they left.
|
|
34
|
+
const leaveSubagent = useCallback(() => {
|
|
35
|
+
setSubagentId((current) => {
|
|
36
|
+
if (current !== undefined) {
|
|
37
|
+
setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
38
|
+
}
|
|
39
|
+
return undefined
|
|
40
|
+
})
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const openSubagentNonce = openSubagent?.nonce
|
|
44
|
+
const openSubagentId = openSubagent?.toolUseId
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (openSubagentId === undefined) {
|
|
47
|
+
leaveSubagent()
|
|
48
|
+
} else {
|
|
49
|
+
setSubagentId(openSubagentId)
|
|
50
|
+
}
|
|
51
|
+
// Keyed on the nonce alone: an unchanged nonce is the host echoing our own report, and
|
|
52
|
+
// acting on it starts the URL → panel → URL cycle.
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
}, [openSubagentNonce])
|
|
55
|
+
|
|
56
|
+
// A reveal targets the root transcript, so it closes whatever frame is open — without the
|
|
57
|
+
// return reveal, which would fight the requested one.
|
|
58
|
+
const revealNonce = reveal?.nonce
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (revealNonce === undefined) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
setSubagentId(undefined)
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [revealNonce])
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (subagentId === undefined) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const onKey = (event: KeyboardEvent) => {
|
|
72
|
+
if (event.key !== 'Escape' || event.defaultPrevented) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
leaveSubagent()
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener('keydown', onKey)
|
|
78
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
79
|
+
}, [subagentId, leaveSubagent])
|
|
80
|
+
|
|
81
|
+
const onSubagentChangeRef = useRef(onSubagentChange)
|
|
82
|
+
onSubagentChangeRef.current = onSubagentChange
|
|
83
|
+
const reportedSubagentId = useRef<string | undefined>(undefined)
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (reportedSubagentId.current === subagentId) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
reportedSubagentId.current = subagentId
|
|
89
|
+
onSubagentChangeRef.current?.(subagentId)
|
|
90
|
+
}, [subagentId])
|
|
91
|
+
|
|
92
|
+
const frameItems = useMemo(() => (subagentId === undefined ? [] : subagentItems(items, subagentId)), [items, subagentId])
|
|
93
|
+
const task = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
subagentId === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: items.find((item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId),
|
|
98
|
+
[items, subagentId],
|
|
99
|
+
)
|
|
100
|
+
const fallbackLabel = useMemo(() => {
|
|
101
|
+
const record = session?.subagents?.find((sub) => sub.toolUseId === subagentId)
|
|
102
|
+
return record ? subagentLabel(record) : 'Sub-agent'
|
|
103
|
+
}, [session, subagentId])
|
|
104
|
+
|
|
105
|
+
return { subagentId, enterSubagent: setSubagentId, leaveSubagent, returnReveal, frameItems, task, fallbackLabel }
|
|
106
|
+
}
|
|
@@ -1,37 +1,33 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The jump: aim at a virtual row, land exactly. Every jump on the transcript
|
|
3
|
-
* surface — the catch-up strip's, and each of the scrubber's marks — comes
|
|
4
|
-
* through here, along with the re-pin the catch-up strip's dismiss uses.
|
|
5
|
-
* **Instant**, like everything else that moves the scroll position: VS Code's
|
|
6
|
-
* editor jumps when you click its scrollbar and so does this.
|
|
7
|
-
*
|
|
8
|
-
* The aim loop survives from before the height calculator, with its job
|
|
9
|
-
* changed twice. It used to be the mechanism: offsets over unmeasured spans
|
|
10
|
-
* were sums of flat estimates (~3300px off over 600 rows), and the loop
|
|
11
|
-
* walked them in up to six passes, each pass better than the last because
|
|
12
|
-
* the rows it crossed had measured. With `estimateSize` computed the first
|
|
13
|
-
* aim lands within a line or two; with the travel now instant there is also
|
|
14
|
-
* no in-flight animation suppressing the virtualizer's own adjustments — the
|
|
15
|
-
* condition that made a *single* smooth `scrollToIndex` unable to
|
|
16
|
-
* self-correct at all. What is left is convergence insurance for the two
|
|
17
|
-
* things the calculator cannot know: the recap row's estimated constant, and
|
|
18
|
-
* flagged content (CJK, compressed tables). It is cheap now — each pass is a
|
|
19
|
-
* jump, not a journey, so a pass that was already on target costs nothing and
|
|
20
|
-
* the check exits after one.
|
|
21
|
-
*/
|
|
22
1
|
import { useEffect, useRef, type RefObject } from 'react'
|
|
23
2
|
import type { Virtualizer } from '@tanstack/react-virtual'
|
|
24
3
|
import type { useStickToBottomContext } from 'use-stick-to-bottom'
|
|
25
4
|
import type { HeightEpoch } from '../terminal/height.ts'
|
|
26
5
|
import { gapBefore, type TranscriptRow } from './transcript-rows.ts'
|
|
27
6
|
|
|
28
|
-
/** How many times a jump re-checks its landing, and how long it waits before
|
|
29
|
-
* doing so. Both are much smaller than the smooth era's 6 × 300ms: a pass is
|
|
30
|
-
* now a jump rather than a journey, so the only thing being waited on is a
|
|
31
|
-
* layout pass in which the crossed rows measure. */
|
|
32
7
|
const AIM_PASSES = 4
|
|
33
8
|
const AIM_SETTLE_MS = 50
|
|
34
9
|
|
|
10
|
+
/** How long a send's re-pin outlasts the click — long enough to eat a trackpad's momentum tail. */
|
|
11
|
+
export const REPIN_HOLD_MS = 750
|
|
12
|
+
|
|
13
|
+
type RepinTarget = Pick<ReturnType<typeof useStickToBottomContext>, 'scrollToBottom' | 'state'>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The send re-pin: a held, escape-proof pin, not one `scrollToBottom('instant')` — a trackpad's
|
|
17
|
+
* trailing momentum tick reads as escape intent and aborts the one-shot before its first frame
|
|
18
|
+
* (GOTCHAS "The send re-pin is a held pin"). Every line is load-bearing: clear the stale escape
|
|
19
|
+
* flag, hold through the momentum tail, seed the `ignoreEscapes` record the library only
|
|
20
|
+
* installs one rAF too late, and press the first scroll synchronously. Deliberate detach
|
|
21
|
+
* (`stopScroll`, drag-selection) still wins immediately.
|
|
22
|
+
*/
|
|
23
|
+
export function repinToBottom(stick: RepinTarget): void {
|
|
24
|
+
const { state } = stick
|
|
25
|
+
state.escapedFromLock = false
|
|
26
|
+
const settled = stick.scrollToBottom({ animation: 'instant', ignoreEscapes: true, duration: REPIN_HOLD_MS })
|
|
27
|
+
state.animation = { behavior: 'instant', ignoreEscapes: true, promise: Promise.resolve(settled) }
|
|
28
|
+
state.scrollTop = state.calculatedTargetScrollTop
|
|
29
|
+
}
|
|
30
|
+
|
|
35
31
|
export function useTranscriptJumps(options: {
|
|
36
32
|
rows: TranscriptRow[]
|
|
37
33
|
terminal: boolean
|
|
@@ -45,89 +41,44 @@ export function useTranscriptJumps(options: {
|
|
|
45
41
|
jumpToRecapRef?: RefObject<(() => void) | null>
|
|
46
42
|
repinRef?: RefObject<(() => void) | null>
|
|
47
43
|
}): (rowIndex: number, align?: 'start' | 'center') => void {
|
|
48
|
-
const {
|
|
49
|
-
rows,
|
|
50
|
-
terminal,
|
|
51
|
-
stickyPrompt,
|
|
52
|
-
epoch,
|
|
53
|
-
promptRows,
|
|
54
|
-
scrollElement,
|
|
55
|
-
rowsRef,
|
|
56
|
-
virtualizer,
|
|
57
|
-
stick,
|
|
58
|
-
jumpToRecapRef,
|
|
59
|
-
repinRef,
|
|
60
|
-
} = options
|
|
44
|
+
const { rows, terminal, stickyPrompt, epoch, promptRows, scrollElement, rowsRef, virtualizer, stick, jumpToRecapRef, repinRef } = options
|
|
61
45
|
|
|
62
|
-
// The pending re-aim lives in a ref, and this is the whole reason: the
|
|
63
|
-
// closure is rebuilt every render to keep `rows` fresh, so anything held in
|
|
64
|
-
// its scope is torn down every render too — and a jump in flight re-renders
|
|
65
|
-
// constantly, because that is what rows mounting *is*. A timer in the
|
|
66
|
-
// closure would be cancelled by the very work it is waiting for.
|
|
67
46
|
const aimTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
68
47
|
useEffect(() => () => clearTimeout(aimTimer.current), [])
|
|
69
48
|
|
|
70
|
-
// `align` is the caller's, and every caller on this surface now asks for
|
|
71
|
-
// `'start'`: a jump target is a place to *start reading* — what you came for
|
|
72
|
-
// runs downward from it — so its line lands at exactly the top edge with the
|
|
73
|
-
// screen below it to read into, and centring wastes half the viewport on what
|
|
74
|
-
// you have already read. The recap boundary was the one exception, argued as
|
|
75
|
-
// "a seam, and seeing a little of what came before is how you place it"; in
|
|
76
|
-
// practice the seam is a starting line too. `'center'` survives as the
|
|
77
|
-
// default because it is what `scrollIntoView` means and a future caller may
|
|
78
|
-
// want it, not because anything here uses it.
|
|
79
|
-
//
|
|
80
|
-
// The two aligns aim differently, and the split is load-bearing. `'start'`
|
|
81
|
-
// computes the target offset itself — never `scrollIntoView` — because a
|
|
82
|
-
// prompt row is `position: sticky` and scrollIntoView aims at the element's
|
|
83
|
-
// *current* rect, which for a stuck row is wherever it is pinned: the jump
|
|
84
|
-
// would be a no-op on the very rows the scrubber's left lane points at. The
|
|
85
|
-
// target is the row's virtual start plus the rows container's own offset in
|
|
86
|
-
// the scroll content, plus the row's gap padding (the blank line is padding
|
|
87
|
-
// *on* the row, and the reader asked for the line, not its air) — which also
|
|
88
|
-
// lands exactly on the sticky engage threshold, so the row arrives pinned.
|
|
89
|
-
// Re-aims recompute it while rows crossed by the travel measure.
|
|
90
|
-
//
|
|
91
|
-
// `stopScroll()` first, because the pin spring is the other `scrollTop`
|
|
92
|
-
// writer and this is the library's own switch for "the user is leaving the
|
|
93
|
-
// bottom".
|
|
94
49
|
const jumpToRow = (rowIndex: number, align: 'start' | 'center' = 'center') => {
|
|
95
|
-
if (rowIndex < 0 || rowIndex >= rows.length || !scrollElement)
|
|
50
|
+
if (rowIndex < 0 || rowIndex >= rows.length || !scrollElement) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
96
53
|
stick.stopScroll()
|
|
97
54
|
clearTimeout(aimTimer.current)
|
|
55
|
+
// `'start'` computes the offset itself, never `scrollIntoView`: a prompt row is `position: sticky`, and while stuck its current rect is where it is pinned, so the jump would no-op on exactly the rows the scrubber's left lane points at.
|
|
98
56
|
if (align === 'start') {
|
|
99
|
-
|
|
100
|
-
// not its air.
|
|
101
|
-
const linePad = (index: number) =>
|
|
102
|
-
terminal && epoch && index > 0 && gapBefore(rows, index) ? epoch.line : 0
|
|
57
|
+
const linePad = (index: number) => (terminal && epoch && index > 0 && gapBefore(rows, index) ? epoch.line : 0)
|
|
103
58
|
const target = () => {
|
|
104
59
|
const start = virtualizer.measurementsCache[rowIndex]?.start ?? 0
|
|
105
60
|
const spacer = rowsRef.current
|
|
106
|
-
? rowsRef.current.getBoundingClientRect().top -
|
|
107
|
-
scrollElement.getBoundingClientRect().top +
|
|
108
|
-
scrollElement.scrollTop
|
|
61
|
+
? rowsRef.current.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top + scrollElement.scrollTop
|
|
109
62
|
: 0
|
|
110
63
|
let top = start + spacer + linePad(rowIndex)
|
|
111
|
-
// A non-prompt target lands *below* the pinned prompt, not under it:
|
|
112
|
-
// at this offset the turn's own prompt head is stuck at the top, and a
|
|
113
|
-
// jump that puts the line at zero puts it exactly behind that band —
|
|
114
|
-
// the scrubber's answer marks would all land their first line hidden.
|
|
115
|
-
// The head is one line tall. A jump *to* a prompt keeps zero: it
|
|
116
|
-
// becomes the pinned row itself.
|
|
117
64
|
if (terminal && stickyPrompt && epoch && !promptRows.includes(rowIndex)) {
|
|
118
65
|
const pinned = promptRows.some((index) => index < rowIndex)
|
|
119
|
-
if (pinned)
|
|
66
|
+
if (pinned) {
|
|
67
|
+
top -= epoch.line
|
|
68
|
+
}
|
|
120
69
|
}
|
|
121
70
|
return Math.round(top)
|
|
122
71
|
}
|
|
123
72
|
const aim = (attempt: number) => {
|
|
124
73
|
const top = target()
|
|
125
74
|
scrollElement.scrollTo({ top, behavior: 'instant' })
|
|
126
|
-
if (attempt >= AIM_PASSES)
|
|
75
|
+
if (attempt >= AIM_PASSES) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
127
78
|
aimTimer.current = setTimeout(() => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
79
|
+
if (Math.abs(scrollElement.scrollTop - target()) > 1) {
|
|
80
|
+
aim(attempt + 1)
|
|
81
|
+
}
|
|
131
82
|
}, AIM_SETTLE_MS)
|
|
132
83
|
}
|
|
133
84
|
aim(0)
|
|
@@ -139,23 +90,20 @@ export function useTranscriptJumps(options: {
|
|
|
139
90
|
row.scrollIntoView({ behavior: 'instant', block: align })
|
|
140
91
|
return
|
|
141
92
|
}
|
|
142
|
-
if (attempt >= AIM_PASSES)
|
|
93
|
+
if (attempt >= AIM_PASSES) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
143
96
|
virtualizer.scrollToIndex(rowIndex, { align, behavior: 'auto' })
|
|
144
|
-
// A frame or two, not a journey: the aim is only better once the rows
|
|
145
|
-
// crossed have mounted *and* been measured, and that is a layout pass
|
|
146
|
-
// away — but with the jump instant there is nothing else to wait for.
|
|
147
97
|
aimTimer.current = setTimeout(() => aim(attempt + 1), AIM_SETTLE_MS)
|
|
148
98
|
}
|
|
149
99
|
aim(0)
|
|
150
100
|
}
|
|
151
101
|
|
|
152
102
|
useEffect(() => {
|
|
153
|
-
if (!jumpToRecapRef)
|
|
103
|
+
if (!jumpToRecapRef) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
154
106
|
jumpToRecapRef.current = () => {
|
|
155
|
-
// `'start'`, like the scrubber's marks. The seam is a place to *start
|
|
156
|
-
// reading* — everything you missed runs downward from it — so it belongs
|
|
157
|
-
// at the top edge with the screen below it to read into. Centring it
|
|
158
|
-
// spent half the viewport on rows you had already read.
|
|
159
107
|
jumpToRow(
|
|
160
108
|
rows.findIndex((row) => row.key === 'recap'),
|
|
161
109
|
'start',
|
|
@@ -166,20 +114,14 @@ export function useTranscriptJumps(options: {
|
|
|
166
114
|
}
|
|
167
115
|
})
|
|
168
116
|
|
|
169
|
-
// Re-pin to the bottom. Published the same way as the jump above, and for the
|
|
170
|
-
// same reason: only the scroll context can do it, and it lives in here.
|
|
171
|
-
//
|
|
172
|
-
// `'instant'`, never the follow spring. The spring exists to keep up with a
|
|
173
|
-
// stream a few pixels at a time; from a reader who had scrolled a long way up
|
|
174
|
-
// it would animate the whole way down, which is the smooth-scroll-on-switch
|
|
175
|
-
// complaint this component already carries two guards against. The target is
|
|
176
|
-
// trustworthy because the bottom is `totalSize`, which the height calculator
|
|
177
|
-
// makes exact — unlike a mid-list row, which is why `jumpToRow` needs its
|
|
178
|
-
// aim/re-aim loop and this needs none.
|
|
179
117
|
useEffect(() => {
|
|
180
|
-
if (!repinRef)
|
|
118
|
+
if (!repinRef) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
181
121
|
repinRef.current = () => {
|
|
182
|
-
|
|
122
|
+
// A pending jump's re-aim would land after the re-pin and yank the view back up.
|
|
123
|
+
clearTimeout(aimTimer.current)
|
|
124
|
+
repinToBottom(stick)
|
|
183
125
|
}
|
|
184
126
|
return () => {
|
|
185
127
|
repinRef.current = null
|
|
@@ -18,7 +18,9 @@ export function AnimatedPlaceholder({ texts, interval = 3000 }: AnimatedPlacehol
|
|
|
18
18
|
const [index, setIndex] = useState(0)
|
|
19
19
|
|
|
20
20
|
useEffect(() => {
|
|
21
|
-
if (texts.length <= 1)
|
|
21
|
+
if (texts.length <= 1) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
22
24
|
|
|
23
25
|
const id = setInterval(() => {
|
|
24
26
|
setIndex((prev) => (prev + 1) % texts.length)
|
|
@@ -31,10 +33,9 @@ export function AnimatedPlaceholder({ texts, interval = 3000 }: AnimatedPlacehol
|
|
|
31
33
|
<div
|
|
32
34
|
className="pointer-events-none absolute top-0 left-0 overflow-hidden select-none"
|
|
33
35
|
style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}
|
|
34
|
-
aria-hidden="true"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
className="animate-in fade-in-0 slide-in-from-top-4 duration-300 ease-in-out">
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
>
|
|
38
|
+
<div key={index} className="animate-in fade-in-0 slide-in-from-top-4 duration-300 ease-in-out">
|
|
38
39
|
{texts[index]}
|
|
39
40
|
</div>
|
|
40
41
|
</div>
|
|
@@ -6,14 +6,7 @@
|
|
|
6
6
|
* clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.
|
|
7
7
|
*/
|
|
8
8
|
import type { Segment, ChipSegment } from './types.ts'
|
|
9
|
-
import {
|
|
10
|
-
chipNodeToSegment,
|
|
11
|
-
getChipDisplay,
|
|
12
|
-
getChipTrigger,
|
|
13
|
-
getSelectionRange,
|
|
14
|
-
isChipElement,
|
|
15
|
-
isHTMLElement,
|
|
16
|
-
} from './dom-helpers.ts'
|
|
9
|
+
import { chipNodeToSegment, getChipDisplay, getChipTrigger, getSelectionRange, isChipElement, isHTMLElement } from './dom-helpers.ts'
|
|
17
10
|
import { mergeAdjacentTextSegments } from './prompt-area-engine.ts'
|
|
18
11
|
import { getTextLengthInRange } from './cursor-helpers.ts'
|
|
19
12
|
|
|
@@ -89,11 +82,15 @@ export function serializeFragmentToSegments(fragment: DocumentFragment): Segment
|
|
|
89
82
|
|
|
90
83
|
walkFragmentNodes(fragment, {
|
|
91
84
|
onText: (value) => {
|
|
92
|
-
if (value)
|
|
85
|
+
if (value) {
|
|
86
|
+
segments.push({ type: 'text', text: value })
|
|
87
|
+
}
|
|
93
88
|
},
|
|
94
89
|
onChip: (node) => {
|
|
95
90
|
const chip = chipNodeToSegment(node)
|
|
96
|
-
if (chip)
|
|
91
|
+
if (chip) {
|
|
92
|
+
segments.push(chip)
|
|
93
|
+
}
|
|
97
94
|
},
|
|
98
95
|
onBreak: () => {
|
|
99
96
|
segments.push({ type: 'text', text: '\n' })
|
|
@@ -109,11 +106,15 @@ export function serializeFragmentToSegments(fragment: DocumentFragment): Segment
|
|
|
109
106
|
export function parseSegmentsFromClipboard(json: string): Segment[] | null {
|
|
110
107
|
try {
|
|
111
108
|
const parsed: unknown = JSON.parse(json)
|
|
112
|
-
if (!Array.isArray(parsed))
|
|
109
|
+
if (!Array.isArray(parsed)) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
113
112
|
|
|
114
113
|
const segments: Segment[] = []
|
|
115
114
|
for (const item of parsed) {
|
|
116
|
-
if (!isRecord(item))
|
|
115
|
+
if (!isRecord(item)) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
117
118
|
|
|
118
119
|
if (item.type === 'text' && typeof item.text === 'string') {
|
|
119
120
|
segments.push({ type: 'text', text: item.text })
|
|
@@ -148,13 +149,11 @@ export function parseSegmentsFromClipboard(json: string): Segment[] | null {
|
|
|
148
149
|
* Splits any text segment that straddles the cursor so the pasted content lands
|
|
149
150
|
* exactly at the cursor and nothing before or after is lost.
|
|
150
151
|
*/
|
|
151
|
-
export function insertSegmentsAtCursor(
|
|
152
|
-
currentSegments: Segment[],
|
|
153
|
-
pastedSegments: Segment[],
|
|
154
|
-
editor: HTMLElement,
|
|
155
|
-
): Segment[] {
|
|
152
|
+
export function insertSegmentsAtCursor(currentSegments: Segment[], pastedSegments: Segment[], editor: HTMLElement): Segment[] {
|
|
156
153
|
const range = getSelectionRange()
|
|
157
|
-
if (!range)
|
|
154
|
+
if (!range) {
|
|
155
|
+
return [...currentSegments, ...pastedSegments]
|
|
156
|
+
}
|
|
158
157
|
|
|
159
158
|
const preRange = document.createRange()
|
|
160
159
|
preRange.selectNodeContents(editor)
|
|
@@ -175,7 +174,9 @@ export function insertSegmentsAtCursor(
|
|
|
175
174
|
for (const seg of currentSegments) {
|
|
176
175
|
if (seg.type === 'chip') {
|
|
177
176
|
const chipLen = seg.trigger.length + seg.displayText.length
|
|
178
|
-
if (offset >= cursorOffset)
|
|
177
|
+
if (offset >= cursorOffset) {
|
|
178
|
+
insertOnce()
|
|
179
|
+
}
|
|
179
180
|
result.push(seg)
|
|
180
181
|
offset += chipLen
|
|
181
182
|
continue
|
|
@@ -194,9 +195,13 @@ export function insertSegmentsAtCursor(
|
|
|
194
195
|
const splitAt = cursorOffset - offset
|
|
195
196
|
const before = seg.text.slice(0, splitAt)
|
|
196
197
|
const after = seg.text.slice(splitAt)
|
|
197
|
-
if (before)
|
|
198
|
+
if (before) {
|
|
199
|
+
result.push({ type: 'text', text: before })
|
|
200
|
+
}
|
|
198
201
|
insertOnce()
|
|
199
|
-
if (after)
|
|
202
|
+
if (after) {
|
|
203
|
+
result.push({ type: 'text', text: after })
|
|
204
|
+
}
|
|
200
205
|
}
|
|
201
206
|
offset = segEnd
|
|
202
207
|
}
|