@workerdeck/ui 0.23.0 → 1.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 (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
  2. package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
  3. package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
  4. package/build/format-C_lpFq0l.d.mts +16 -0
  5. package/build/format.d.mts +8 -76
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +291 -1695
  8. package/build/index.mjs +6 -301
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +32 -23
  11. package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
  12. package/build/status-Dm9INVTt.mjs.map +1 -0
  13. package/build/workspace.d.mts +8 -187
  14. package/build/workspace.mjs +13 -144
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +24 -24
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. 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('/')) return undefined
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) return undefined
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) return undefined
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) return undefined
56
- if (!inCode && hit.path.length < text.trim().length * COVERAGE) return undefined
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) return
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) return
66
+ if (!event.metaKey && !event.ctrlKey) {
67
+ return
68
+ }
93
69
  const target = event.target instanceof HTMLElement ? event.target : undefined
94
- if (excluded(target)) return
70
+ if (excluded(target)) {
71
+ return
72
+ }
95
73
  const hit = hitFor(target)
96
- if (!hit) return
97
- // Capture phase and stopped here: the row underneath is usually pressable
98
- // (a tool call expands), and opening a file is not expanding it.
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) return unmark()
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) return
88
+ if (element === marked) {
89
+ return
90
+ }
109
91
  unmark()
110
- if (!element || excluded(element) || !hitFor(element)) return
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) unmark()
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('/')) return path
145
- if (!cwd) return path
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) return
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
- // The gap padding a row's *line* sits below the target is the line,
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) top -= epoch.line
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) return
75
+ if (attempt >= AIM_PASSES) {
76
+ return
77
+ }
127
78
  aimTimer.current = setTimeout(() => {
128
- // Off target: a row that measured under the jump moved it. (It can no
129
- // longer be "still travelling" — that was the smooth era.)
130
- if (Math.abs(scrollElement.scrollTop - target()) > 1) aim(attempt + 1)
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) return
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) return
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) return
118
+ if (!repinRef) {
119
+ return
120
+ }
181
121
  repinRef.current = () => {
182
- void stick.scrollToBottom('instant')
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) return
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
- <div
36
- key={index}
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) segments.push({ type: 'text', text: 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) segments.push(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)) return null
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)) return null
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) return [...currentSegments, ...pastedSegments]
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) insertOnce()
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) result.push({ type: 'text', text: before })
198
+ if (before) {
199
+ result.push({ type: 'text', text: before })
200
+ }
198
201
  insertOnce()
199
- if (after) result.push({ type: 'text', text: after })
202
+ if (after) {
203
+ result.push({ type: 'text', text: after })
204
+ }
200
205
  }
201
206
  offset = segEnd
202
207
  }