@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.
Files changed (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  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,36 +1,20 @@
1
1
  'use client'
2
2
 
3
3
  import { cn } from '../../lib/utils.ts'
4
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
- import type {
6
- Segment,
7
- TriggerConfig,
8
- ActiveTrigger,
9
- TriggerSuggestion,
10
- ChipSegment,
11
- PromptAreaHandle,
12
- } from './types.ts'
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
5
+ import type { Segment, TriggerConfig, ActiveTrigger, TriggerSuggestion, ChipSegment, PromptAreaHandle } from './types.ts'
13
6
  import {
14
7
  detectActiveTrigger,
15
- isValidTriggerPosition,
16
8
  segmentsToPlainText,
17
9
  plainTextToSegments,
10
+ segmentsContentEqual,
18
11
  segmentsEqual,
19
12
  resolveChip,
20
13
  resolveText,
21
- removeChipAtIndex,
22
- revertChipAtIndex,
23
- replaceTextRange,
24
- toggleMarkdownWrap,
25
14
  truncateSegmentsToLength,
26
15
  } from './prompt-area-engine.ts'
27
16
  import {
28
- getListContext,
29
17
  autoFormatListPrefix,
30
- insertListContinuation,
31
- indentListItem,
32
- outdentListItem,
33
- removeListPrefix,
34
18
  normalizeListPrefixes,
35
19
  renumberOrderedListSegments,
36
20
  remapOffset,
@@ -39,34 +23,18 @@ import {
39
23
  import {
40
24
  isHTMLElement,
41
25
  isChipElement,
42
- isLinkElement,
43
26
  isBRElement,
44
27
  chipNodeToSegment,
45
- getChipAutoResolved,
46
- getDirectChildContaining,
47
- indexOfChildNode,
48
- domChildIndexToSegmentIndex,
49
28
  normalizeEditorDOM,
50
29
  decorateEditor,
51
30
  safeJsonStringify,
52
- getSelectionRange,
53
31
  } from './dom-helpers.ts'
54
- import {
55
- saveCursorPosition,
56
- restoreCursorPosition,
57
- getCursorOffset,
58
- setCursorAtOffset,
59
- createRangeAtOffset,
60
- getSelectionOffsets,
61
- setSelectionAtOffsets,
62
- } from './cursor-helpers.ts'
32
+ import { getCursorOffset, setCursorAtOffset, createRangeAtOffset, getSelectionOffsets, setSelectionAtOffsets } from './cursor-helpers.ts'
63
33
  import { usePromptAreaEvents } from './use-prompt-area-events.ts'
34
+ import { usePromptAreaKeydown } from './use-prompt-area-keydown.ts'
35
+ import { useChipEditing } from './use-chip-editing.ts'
64
36
  import { useTriggerSearch } from './use-trigger-search.ts'
65
37
 
66
- // ---------------------------------------------------------------------------
67
- // Types
68
- // ---------------------------------------------------------------------------
69
-
70
38
  type UsePromptAreaOptions = {
71
39
  value: Segment[]
72
40
  onChange: (segments: Segment[]) => void
@@ -119,14 +87,15 @@ type UsePromptAreaReturn = {
119
87
  /** Debounce interval for grouping typed characters into a single undo snapshot */
120
88
  const UNDO_DEBOUNCE_MS = 300
121
89
 
122
- // ---------------------------------------------------------------------------
123
- // Hook
124
- // ---------------------------------------------------------------------------
90
+ // Shared default so an omitted `triggers` prop keeps a stable identity across
91
+ // renders — a fresh [] would re-create renderSegmentsToDOM (and everything
92
+ // keyed on it) on every parent render.
93
+ const NO_TRIGGERS: TriggerConfig[] = []
125
94
 
126
95
  export function usePromptArea({
127
96
  value,
128
97
  onChange,
129
- triggers = [],
98
+ triggers = NO_TRIGGERS,
130
99
  disabled = false,
131
100
  onSubmit,
132
101
  onEscape,
@@ -149,46 +118,7 @@ export function usePromptArea({
149
118
  const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0)
150
119
  const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)
151
120
 
152
- // Chip whose dropdown was reopened via `reopenOnChipClick`. While set, the
153
- // active dropdown edits this chip in place instead of resolving typed text.
154
- // Cleared on dismiss and by any fresh trigger detection (typing).
155
- //
156
- // `segIndex` is the segment index at CLICK time (computed from the live DOM,
157
- // so it's always accurate then) — deliberately not the DOM node itself,
158
- // which can detach if `renderSegmentsToDOM` re-renders while the dropdown is
159
- // open (external value update, undo/redo). At selection time we re-verify
160
- // this index still holds the same chip and fall back to a trigger+value
161
- // search if the model shifted underneath, instead of silently no-op'ing.
162
- const editingChip = useRef<{ chip: ChipSegment; segIndex: number } | null>(null)
163
-
164
- // The DOM node of the chip currently being edited via `reopenOnChipClick`,
165
- // kept in lockstep with `editingChip`/`activeTrigger` (set when opened,
166
- // cleared by `dismissTrigger`). Used only to answer "is THIS exact chip
167
- // element the one whose dropdown is open right now" by reference identity —
168
- // never by trigger+value, which can't distinguish two chips that happen to
169
- // share the same value.
170
- const openChipNode = useRef<HTMLElement | null>(null)
171
-
172
- // Set by `handleMouseDown` to the chip node a mousedown landed on, but only
173
- // when that node === `openChipNode.current` at that instant; read and
174
- // cleared by the following `handleClick` to distinguish "reopen" from
175
- // "toggle closed" for that one click. A real `onMouseDown` (bubble-phase,
176
- // attached to the editor root) is used instead of piggybacking on
177
- // `dismissTrigger` because DOM bubbling reaches the editor root before it
178
- // reaches `document` (where TriggerPopover's outside-click dismiss listens),
179
- // so this always observes `openChipNode` before that dismiss clears it —
180
- // and unlike a `dismissTrigger`-driven flag, it is scoped to mousedowns on
181
- // this exact node, so Escape/blur/an unrelated dismiss can never poison a
182
- // later, unrelated click on the same chip.
183
- const suppressReopenChip = useRef<HTMLElement | null>(null)
184
-
185
- const {
186
- suggestions,
187
- suggestionsLoading,
188
- suggestionsError,
189
- search: runSearch,
190
- reset: resetSearch,
191
- } = useTriggerSearch()
121
+ const { suggestions, suggestionsLoading, suggestionsError, search: runSearch, reset: resetSearch } = useTriggerSearch()
192
122
 
193
123
  // Guard against circular DOM <-> model syncs
194
124
  const isSyncing = useRef(false)
@@ -198,13 +128,13 @@ export function usePromptArea({
198
128
  const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
199
129
  const undoBaseState = useRef<Segment[] | null>(null)
200
130
 
201
- // -----------------------------------------------------------------------
202
131
  // DOM -> Model: read segments from the contentEditable DOM
203
- // -----------------------------------------------------------------------
204
132
 
205
133
  const readSegmentsFromDOM = useCallback((): Segment[] => {
206
134
  const editor = editorRef.current
207
- if (!editor) return []
135
+ if (!editor) {
136
+ return []
137
+ }
208
138
 
209
139
  const segments: Segment[] = []
210
140
  // Track whether the editor holds any real content (text/chip) or a sentinel
@@ -235,7 +165,6 @@ export function usePromptArea({
235
165
  }
236
166
  segments.push({ type: 'text', text: '\n' })
237
167
  } else if (isHTMLElement(node)) {
238
- // Unknown element — extract text content
239
168
  const text = node.textContent ?? ''
240
169
  if (text) {
241
170
  segments.push({ type: 'text', text })
@@ -244,30 +173,33 @@ export function usePromptArea({
244
173
  }
245
174
  }
246
175
 
247
- // When the user empties the editor (types something, then deletes it all),
248
- // the browser leaves a lone filler <br> so the contentEditable block stays
249
- // visible and focusable. Reading that <br> as a "\n" text segment would make
250
- // `value` permanently non-empty and keep the placeholder hidden forever.
251
- // A newline we actually rendered always carries surrounding text/chip
252
- // content or a trailing sentinel <br>, so when neither is present the only
253
- // <br> nodes are filler and the editor is genuinely empty.
254
- if (!hasRealContent && !hasSentinel) return []
176
+ // An emptied editor keeps a lone browser filler <br>; reading it as "\n" would
177
+ // make `value` permanently non-empty and hide the placeholder forever. A newline
178
+ // we rendered always carries surrounding content or a trailing sentinel <br>, so
179
+ // with neither present every <br> is filler and the editor is genuinely empty.
180
+ if (!hasRealContent && !hasSentinel) {
181
+ return []
182
+ }
255
183
 
256
184
  return segments
257
185
  }, [])
258
186
 
259
- // -----------------------------------------------------------------------
260
187
  // Model -> DOM: render segments into the contentEditable div
261
- // -----------------------------------------------------------------------
262
188
 
263
189
  const renderSegmentsToDOM = useCallback(
264
190
  (segments: Segment[]) => {
265
191
  const editor = editorRef.current
266
- if (!editor) return
192
+ if (!editor) {
193
+ return
194
+ }
267
195
 
268
196
  isSyncing.current = true
269
197
 
270
- const savedCursor = saveCursorPosition(editor)
198
+ // Save the caret as a plain-text offset, not a {nodeIndex, offset} pair:
199
+ // the rebuild plus decorateEditor re-chunk the child nodes, so a node
200
+ // index recorded against the old tree lands in the wrong node and its
201
+ // clamped offset silently moves the caret.
202
+ const savedCursorOffset = getCursorOffset(editor)
271
203
 
272
204
  // Clear DOM safely (no innerHTML assignment)
273
205
  while (editor.firstChild) {
@@ -286,7 +218,6 @@ export function usePromptArea({
286
218
  }
287
219
  }
288
220
  } else {
289
- // Render chip as non-editable span
290
221
  const chip = document.createElement('span')
291
222
  chip.contentEditable = 'false'
292
223
  chip.dataset.chipTrigger = seg.trigger
@@ -304,11 +235,7 @@ export function usePromptArea({
304
235
  const triggerConfig = triggers.find((t) => t.char === seg.trigger)
305
236
  const chipStyle = triggerConfig?.chipStyle ?? 'pill'
306
237
  chip.dataset.chipStyle = chipStyle
307
- chip.className = cn(
308
- 'prompt-area-chip',
309
- chipStyle === 'inline' && 'prompt-area-chip--inline',
310
- triggerConfig?.chipClassName,
311
- )
238
+ chip.className = cn('prompt-area-chip', chipStyle === 'inline' && 'prompt-area-chip--inline', triggerConfig?.chipClassName)
312
239
  chip.textContent = `${seg.trigger}${seg.displayText}`
313
240
  chip.setAttribute('role', 'button')
314
241
  chip.setAttribute('tabindex', '-1')
@@ -326,8 +253,8 @@ export function usePromptArea({
326
253
  // Decorate URLs, markdown formatting, and list bullets in text nodes
327
254
  decorateEditor(editor, markdownEnabled)
328
255
 
329
- if (savedCursor) {
330
- restoreCursorPosition(editor, savedCursor)
256
+ if (savedCursorOffset !== null) {
257
+ setCursorAtOffset(editor, savedCursorOffset)
331
258
  }
332
259
 
333
260
  lastRenderedValue.current = segments
@@ -336,9 +263,27 @@ export function usePromptArea({
336
263
  [triggers, markdownEnabled],
337
264
  )
338
265
 
339
- // -----------------------------------------------------------------------
266
+ // The chip-click editing path (`reopenOnChipClick`) lives in use-chip-editing.ts.
267
+
268
+ const chipEditing = useChipEditing({
269
+ editorRef,
270
+ triggers,
271
+ disabled,
272
+ onChange,
273
+ renderSegmentsToDOM,
274
+ onChipClick,
275
+ onChipAdd,
276
+ onChipDelete,
277
+ onLinkClick,
278
+ setActiveTrigger,
279
+ setSelectedSuggestionIndex,
280
+ setTriggerRect,
281
+ runSearch,
282
+ activeTrigger,
283
+ suggestions,
284
+ })
285
+
340
286
  // Trigger detection (extracted so events module can call it)
341
- // -----------------------------------------------------------------------
342
287
 
343
288
  // Builds the insertChip handed to callback/launch activations: replaces the
344
289
  // trigger's range with a chip and notifies onChipAdd.
@@ -359,27 +304,32 @@ export function usePromptArea({
359
304
  ...(chip.data !== undefined ? { data: chip.data } : {}),
360
305
  })
361
306
  const editor = editorRef.current
362
- if (editor) setCursorAtOffset(editor, chipResult.cursorOffset)
307
+ if (editor) {
308
+ setCursorAtOffset(editor, chipResult.cursorOffset)
309
+ }
363
310
  },
364
311
  [onChange, renderSegmentsToDOM, onChipAdd],
365
312
  )
366
313
 
367
314
  const runTriggerDetection = useCallback(() => {
368
315
  const editor = editorRef.current
369
- if (!editor) return
316
+ if (!editor) {
317
+ return
318
+ }
370
319
 
371
320
  const segments = readSegmentsFromDOM()
372
321
  const plainText = segmentsToPlainText(segments)
373
322
  const cursorPos = getCursorOffset(editor)
374
323
 
375
- if (cursorPos === null) return
324
+ if (cursorPos === null) {
325
+ return
326
+ }
376
327
 
377
328
  const detected = detectActiveTrigger(plainText, cursorPos, triggers)
378
329
 
379
330
  // Typing supersedes a chip-click dropdown: whichever branch we take next,
380
331
  // the popover no longer edits the clicked chip.
381
- editingChip.current = null
382
- openChipNode.current = null
332
+ chipEditing.clear()
383
333
 
384
334
  if (detected) {
385
335
  setActiveTrigger(detected)
@@ -398,12 +348,10 @@ export function usePromptArea({
398
348
  }
399
349
  }
400
350
 
401
- // Fetch suggestions for dropdown mode
402
351
  if (detected.config.mode === 'dropdown' && detected.config.onSearch) {
403
352
  runSearch(detected.query, detected.config)
404
353
  }
405
354
 
406
- // Fire callback for callback mode
407
355
  if (detected.config.mode === 'callback' && detected.config.onActivate) {
408
356
  detected.config.onActivate({
409
357
  text: plainText,
@@ -415,23 +363,18 @@ export function usePromptArea({
415
363
  setActiveTrigger(null)
416
364
  resetSearch()
417
365
  }
418
- }, [triggers, readSegmentsFromDOM, buildInsertChip, resetSearch, runSearch])
366
+ }, [triggers, readSegmentsFromDOM, buildInsertChip, resetSearch, runSearch, chipEditing])
419
367
 
420
- // -----------------------------------------------------------------------
421
368
  // Dismiss trigger
422
- // -----------------------------------------------------------------------
423
369
 
424
370
  const dismissTrigger = useCallback(() => {
425
- editingChip.current = null
426
- openChipNode.current = null
371
+ chipEditing.clear()
427
372
  setActiveTrigger(null)
428
373
  setSelectedSuggestionIndex(0)
429
374
  resetSearch()
430
- }, [resetSearch])
375
+ }, [resetSearch, chipEditing])
431
376
 
432
- // -----------------------------------------------------------------------
433
377
  // Wire up edge-case event handlers
434
- // -----------------------------------------------------------------------
435
378
 
436
379
  const events = usePromptAreaEvents({
437
380
  editorRef,
@@ -451,13 +394,23 @@ export function usePromptArea({
451
394
  onImagePaste,
452
395
  })
453
396
 
454
- // -----------------------------------------------------------------------
455
397
  // Sync value prop -> DOM on external changes
456
- // -----------------------------------------------------------------------
457
398
 
458
- useEffect(() => {
459
- if (isSyncing.current) return
460
- if (segmentsEqual(value, lastRenderedValue.current)) return
399
+ // Layout effect, not passive: a passive copy of this effect can still be
400
+ // queued when the next keystroke's input event arrives, and React flushes
401
+ // pending passive effects before rendering that keystroke's onChange. The
402
+ // stale flush would compare an older render's `value` against the
403
+ // lastRenderedValue that handleInput just advanced, read its own echo lag as
404
+ // an external change, and rewrite the editor to pre-keystroke content —
405
+ // leaving the caret behind the character once the echo re-rendered it.
406
+ // Inside the commit, `value` can never be older than lastRenderedValue.
407
+ useLayoutEffect(() => {
408
+ if (isSyncing.current) {
409
+ return
410
+ }
411
+ if (segmentsEqual(value, lastRenderedValue.current)) {
412
+ return
413
+ }
461
414
 
462
415
  // Normalize list prefixes (e.g., "- " → "• " when markdown is on)
463
416
  // so externally-provided segments render bullet characters correctly.
@@ -469,14 +422,27 @@ export function usePromptArea({
469
422
  }
470
423
  }
471
424
 
425
+ // A rebuild churns the caret and the decorations, so skip it when the
426
+ // editor already shows exactly this content — e.g. a consumer echoed our
427
+ // onChange back re-chunked, or the DOM is split by decorations. Adopt the
428
+ // value so the equality guard above short-circuits from here on.
429
+ if (segmentsContentEqual(readSegmentsFromDOM(), value)) {
430
+ lastRenderedValue.current = value
431
+ return
432
+ }
433
+
472
434
  renderSegmentsToDOM(value)
473
- }, [value, renderSegmentsToDOM, markdownEnabled, normalizeBullets, onChange])
435
+ }, [value, readSegmentsFromDOM, renderSegmentsToDOM, markdownEnabled, normalizeBullets, onChange])
474
436
 
475
437
  // Re-render when markdown mode changes to apply/strip decorations
476
438
  // Also convert bullet characters: • ↔ - in text segments
439
+ // Layout for the same reason as the value sync above: a queued passive copy
440
+ // could flush mid-typing and render a stale `value`.
477
441
  const prevMarkdown = useRef(markdownEnabled)
478
- useEffect(() => {
479
- if (prevMarkdown.current === markdownEnabled) return
442
+ useLayoutEffect(() => {
443
+ if (prevMarkdown.current === markdownEnabled) {
444
+ return
445
+ }
480
446
  prevMarkdown.current = markdownEnabled
481
447
 
482
448
  const converted = normalizeBullets ? normalizeListPrefixes(value, markdownEnabled) : value
@@ -490,16 +456,18 @@ export function usePromptArea({
490
456
  // Clean up undo debounce timer on unmount
491
457
  useEffect(() => {
492
458
  return () => {
493
- if (undoTimer.current) clearTimeout(undoTimer.current)
459
+ if (undoTimer.current) {
460
+ clearTimeout(undoTimer.current)
461
+ }
494
462
  }
495
463
  }, [])
496
464
 
497
- // -----------------------------------------------------------------------
498
465
  // Handle input events
499
- // -----------------------------------------------------------------------
500
466
 
501
467
  const handleInput = useCallback(() => {
502
- if (isSyncing.current) return
468
+ if (isSyncing.current) {
469
+ return
470
+ }
503
471
 
504
472
  // During IME composition, sync model but skip trigger detection
505
473
  if (events.isComposing.current) {
@@ -556,11 +524,7 @@ export function usePromptArea({
556
524
  // numeric prose ("1985. Born / 2020. Died") untouched.
557
525
  let nextSegments = segments
558
526
  let renumberedCursor: number | null = null
559
- if (
560
- markdownEnabled &&
561
- savedCursorOffset !== null &&
562
- hasOrderedListRun(segmentsToPlainText(segments))
563
- ) {
527
+ if (markdownEnabled && savedCursorOffset !== null && hasOrderedListRun(segmentsToPlainText(segments))) {
564
528
  const renumbered = renumberOrderedListSegments(segments)
565
529
  if (renumbered.edits.length > 0) {
566
530
  nextSegments = renumbered.segments
@@ -576,7 +540,9 @@ export function usePromptArea({
576
540
 
577
541
  lastRenderedValue.current = nextSegments
578
542
  onChange(nextSegments)
579
- if (undoTimer.current) clearTimeout(undoTimer.current)
543
+ if (undoTimer.current) {
544
+ clearTimeout(undoTimer.current)
545
+ }
580
546
  undoTimer.current = setTimeout(() => {
581
547
  if (undoBaseState.current) {
582
548
  events.pushUndo(undoBaseState.current)
@@ -601,333 +567,13 @@ export function usePromptArea({
601
567
  }
602
568
 
603
569
  runTriggerDetection()
604
- }, [
605
- onChange,
606
- readSegmentsFromDOM,
607
- runTriggerDetection,
608
- renderSegmentsToDOM,
609
- markdownEnabled,
610
- normalizeBullets,
611
- maxLength,
612
- events,
613
- ])
614
-
615
- // -----------------------------------------------------------------------
616
- // Chip click delegation
617
- // -----------------------------------------------------------------------
618
-
619
- const handleClick = useCallback(
620
- (e: React.MouseEvent<HTMLDivElement>) => {
621
- const target = e.target
622
- if (!(target instanceof Node)) return
623
-
624
- const editor = editorRef.current
625
- if (!editor) return
626
-
627
- // Walk from the click target up to find a link or chip element
628
- let node: Node | null = target
629
- while (node && node !== editor) {
630
- // Check for URL link click — only navigate on Cmd/Ctrl+Click;
631
- // plain click just positions the cursor for editing.
632
- if (isLinkElement(node)) {
633
- if (e.metaKey || e.ctrlKey) {
634
- e.preventDefault()
635
- onLinkClick?.(node.href)
636
- window.open(node.href, '_blank', 'noopener,noreferrer')
637
- return
638
- }
639
- // Plain click: let the browser place the cursor inside the link text
640
- break
641
- }
642
-
643
- if (isChipElement(node)) {
644
- // Spawn ripple effect. `isChipElement` has already narrowed `node`
645
- // to HTMLElement, so no cast is needed.
646
- const rect = node.getBoundingClientRect()
647
- const ripple = document.createElement('span')
648
- ripple.className = 'prompt-area-chip-ripple'
649
- const size = Math.max(rect.width, rect.height)
650
- ripple.style.width = `${size}px`
651
- ripple.style.height = `${size}px`
652
- ripple.style.left = `${e.clientX - rect.left - size / 2}px`
653
- ripple.style.top = `${e.clientY - rect.top - size / 2}px`
654
- node.appendChild(ripple)
655
- ripple.addEventListener('animationend', () => ripple.remove())
656
-
657
- const chip = chipNodeToSegment(node)
658
- if (chip) {
659
- // Native chip-click dropdown: reopen this trigger's suggestions
660
- // anchored to the chip so the selection can replace it in place.
661
- // Gated on `!disabled` — a disabled composer must not accept edits
662
- // through any path, including this one.
663
- const config = triggers.find((t) => t.char === chip.trigger)
664
- // A click on THIS exact chip element while its own dropdown was
665
- // open just closed it (see `suppressReopenChip` and
666
- // `handleMouseDown`) — treat that as a toggle-close, not a reopen.
667
- const wasOpenForThisChip = suppressReopenChip.current === node
668
- suppressReopenChip.current = null
669
- if (
670
- !disabled &&
671
- !wasOpenForThisChip &&
672
- config?.reopenOnChipClick &&
673
- config.mode === 'dropdown' &&
674
- config.onSearch
675
- ) {
676
- const childIdx = indexOfChildNode(editor, node)
677
- editingChip.current = {
678
- chip,
679
- segIndex: domChildIndexToSegmentIndex(editor, childIdx),
680
- }
681
- openChipNode.current = node
682
- setActiveTrigger({ config, startOffset: 0, query: '' })
683
- setSelectedSuggestionIndex(0)
684
- setTriggerRect(rect)
685
- runSearch('', config)
686
- }
687
- onChipClick?.(chip)
688
- }
689
- return
690
- }
691
- node = node.parentNode
692
- }
693
- },
694
- [onChipClick, onLinkClick, triggers, runSearch, disabled],
695
- )
696
-
697
- // -----------------------------------------------------------------------
698
- // Chip mousedown delegation — feeds `suppressReopenChip` for handleClick's
699
- // toggle-close detection. See `openChipNode`/`suppressReopenChip` above for
700
- // why this needs to be a real mousedown listener rather than piggybacking
701
- // on `dismissTrigger`.
702
- // -----------------------------------------------------------------------
703
-
704
- const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
705
- const target = e.target
706
- const editor = editorRef.current
707
- if (!editor || !(target instanceof Node)) {
708
- suppressReopenChip.current = null
709
- return
710
- }
711
-
712
- let node: Node | null = target
713
- while (node && node !== editor) {
714
- if (isChipElement(node)) {
715
- suppressReopenChip.current = openChipNode.current === node ? node : null
716
- return
717
- }
718
- node = node.parentNode
719
- }
720
- suppressReopenChip.current = null
721
- }, [])
722
-
723
- // -----------------------------------------------------------------------
724
- // Remove a chip node from DOM and sync model
725
- // -----------------------------------------------------------------------
726
-
727
- const removeChipNodeFromDOM = useCallback(
728
- (editor: HTMLElement, chipNode: HTMLElement): boolean => {
729
- const segments = readSegmentsFromDOM()
730
- const chipIdx = indexOfChildNode(editor, chipNode)
731
- if (chipIdx === -1) return false
732
-
733
- const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
734
- const deletedChip = segments[segIdx]
735
- const newSegments = removeChipAtIndex(segments, segIdx)
736
- onChange(newSegments)
737
- renderSegmentsToDOM(newSegments)
738
-
739
- if (deletedChip?.type === 'chip') {
740
- onChipDelete?.(deletedChip)
741
- }
742
-
743
- return true
744
- },
745
- [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
746
- )
747
-
748
- // -----------------------------------------------------------------------
749
- // Revert an auto-resolved chip back to plain text
750
- // -----------------------------------------------------------------------
751
-
752
- const revertChipNodeToText = useCallback(
753
- (editor: HTMLElement, chipNode: HTMLElement): boolean => {
754
- const segments = readSegmentsFromDOM()
755
- const chipIdx = indexOfChildNode(editor, chipNode)
756
- if (chipIdx === -1) return false
757
-
758
- const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
759
- const revertedChip = segments[segIdx]
760
- const result = revertChipAtIndex(segments, segIdx)
761
- if (!result) return false
762
-
763
- // Compute cursor target: plain text offset at end of reverted text
764
- let targetOffset = 0
765
- for (let i = 0; i < segIdx; i++) {
766
- const s = segments[i]
767
- if (s.type === 'text') {
768
- targetOffset += s.text.length
769
- } else {
770
- targetOffset += s.trigger.length + s.displayText.length
771
- }
772
- }
773
- targetOffset += result.revertedText.length
774
-
775
- onChange(result.segments)
776
- renderSegmentsToDOM(result.segments)
777
- setCursorAtOffset(editor, targetOffset)
778
-
779
- if (revertedChip?.type === 'chip') {
780
- onChipDelete?.(revertedChip)
781
- }
782
-
783
- return true
784
- },
785
- [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
786
- )
787
-
788
- // -----------------------------------------------------------------------
789
- // Chip backspace (delete chip behind cursor as whole unit)
790
- // -----------------------------------------------------------------------
791
-
792
- const handleChipBackspace = useCallback((): boolean => {
793
- const editor = editorRef.current
794
- if (!editor) return false
795
-
796
- const range = getSelectionRange()
797
- if (!range || !range.collapsed) return false
798
-
799
- const node = range.startContainer
800
- const offset = range.startOffset
801
-
802
- // Case 1: cursor is at the editor level (between child nodes)
803
- if (node === editor && offset > 0) {
804
- const prevChild = editor.childNodes[offset - 1]
805
- if (prevChild && isChipElement(prevChild)) {
806
- if (getChipAutoResolved(prevChild)) {
807
- return revertChipNodeToText(editor, prevChild)
808
- }
809
- return removeChipNodeFromDOM(editor, prevChild)
810
- }
811
- }
812
-
813
- // Case 2: cursor is at start of a text node, check previous sibling
814
- if (node.nodeType === Node.TEXT_NODE && offset === 0) {
815
- const directChild = getDirectChildContaining(editor, node)
816
- if (!directChild) return false
817
-
818
- let prevSibling = directChild.previousSibling
819
- while (
820
- prevSibling &&
821
- prevSibling.nodeType === Node.TEXT_NODE &&
822
- prevSibling.textContent === ''
823
- ) {
824
- prevSibling = prevSibling.previousSibling
825
- }
826
- if (prevSibling && isChipElement(prevSibling)) {
827
- if (getChipAutoResolved(prevSibling)) {
828
- return revertChipNodeToText(editor, prevSibling)
829
- }
830
- return removeChipNodeFromDOM(editor, prevSibling)
831
- }
832
- }
833
-
834
- return false
835
- }, [removeChipNodeFromDOM, revertChipNodeToText])
836
-
837
- // -----------------------------------------------------------------------
838
- // Chip forward delete (delete chip in front of cursor)
839
- // -----------------------------------------------------------------------
840
-
841
- const handleChipForwardDelete = useCallback((): boolean => {
842
- const editor = editorRef.current
843
- if (!editor) return false
844
-
845
- const range = getSelectionRange()
846
- if (!range || !range.collapsed) return false
847
-
848
- const node = range.startContainer
849
- const offset = range.startOffset
850
-
851
- // Case 1: cursor at the editor level
852
- if (node === editor && offset < editor.childNodes.length) {
853
- const nextChild = editor.childNodes[offset]
854
- if (nextChild && isChipElement(nextChild)) {
855
- return removeChipNodeFromDOM(editor, nextChild)
856
- }
857
- }
858
-
859
- // Case 2: cursor at end of a text node, check next sibling
860
- if (node.nodeType === Node.TEXT_NODE && offset === (node.textContent ?? '').length) {
861
- const directChild = getDirectChildContaining(editor, node)
862
- if (!directChild) return false
863
-
864
- let nextSibling = directChild.nextSibling
865
- while (
866
- nextSibling &&
867
- nextSibling.nodeType === Node.TEXT_NODE &&
868
- nextSibling.textContent === ''
869
- ) {
870
- nextSibling = nextSibling.nextSibling
871
- }
872
- if (nextSibling && isChipElement(nextSibling)) {
873
- return removeChipNodeFromDOM(editor, nextSibling)
874
- }
875
- }
876
-
877
- return false
878
- }, [removeChipNodeFromDOM])
879
-
880
- // -----------------------------------------------------------------------
881
- // Auto-resolve active trigger on space
882
- // -----------------------------------------------------------------------
883
-
884
- const autoResolveActiveTrigger = useCallback(
885
- (trigger: ActiveTrigger) => {
886
- const segments = readSegmentsFromDOM()
887
- const query = trigger.query
888
-
889
- // Create a synthetic suggestion so onSelect can customize display text
890
- const syntheticSuggestion: TriggerSuggestion = {
891
- value: query,
892
- label: query,
893
- }
894
-
895
- const displayText = trigger.config.onSelect?.(syntheticSuggestion) ?? query
896
-
897
- const chipData = {
898
- value: query,
899
- displayText: displayText || query,
900
- autoResolved: true,
901
- }
902
- const result = resolveChip(segments, trigger, chipData)
903
-
904
- onChange(result.segments)
905
- renderSegmentsToDOM(result.segments)
906
-
907
- onChipAdd?.({
908
- type: 'chip',
909
- trigger: trigger.config.char,
910
- ...chipData,
911
- })
912
-
913
- // Position cursor after the auto-resolved chip + trailing space
914
- const editor = editorRef.current
915
- if (editor) {
916
- setCursorAtOffset(editor, result.cursorOffset)
917
- }
918
-
919
- dismissTrigger()
920
- },
921
- [readSegmentsFromDOM, onChange, renderSegmentsToDOM, dismissTrigger, onChipAdd],
922
- )
923
-
924
- // -----------------------------------------------------------------------
925
- // Select a suggestion from the dropdown
926
- // -----------------------------------------------------------------------
570
+ }, [onChange, readSegmentsFromDOM, runTriggerDetection, renderSegmentsToDOM, markdownEnabled, normalizeBullets, maxLength, events])
927
571
 
928
572
  const selectSuggestionInternal = useCallback(
929
573
  (suggestion: TriggerSuggestion) => {
930
- if (!activeTrigger) return
574
+ if (!activeTrigger) {
575
+ return
576
+ }
931
577
 
932
578
  const segments = readSegmentsFromDOM()
933
579
 
@@ -941,7 +587,9 @@ export function usePromptArea({
941
587
  onChange(inserted.segments)
942
588
  renderSegmentsToDOM(inserted.segments)
943
589
  const editor = editorRef.current
944
- if (editor) setCursorAtOffset(editor, inserted.cursorOffset)
590
+ if (editor) {
591
+ setCursorAtOffset(editor, inserted.cursorOffset)
592
+ }
945
593
  dismissTrigger()
946
594
  setTimeout(() => {
947
595
  editorRef.current?.focus()
@@ -957,100 +605,17 @@ export function usePromptArea({
957
605
  data: suggestion.data,
958
606
  }
959
607
 
960
- // Chip-click dropdown (`reopenOnChipClick`): replace the clicked chip in
961
- // place instead of resolving typed trigger text at the caret. Disabled
962
- // is re-checked here (not just at open time) in case the composer
963
- // became disabled while the popover was still open.
964
- const editing = editingChip.current
965
- if (editing) {
966
- const editor = editorRef.current
967
- if (editor && !disabled) {
968
- // Re-verify the click-time index still holds the same chip — the
969
- // model may have shifted (external value update, undo/redo) while
970
- // the dropdown was open. If it moved, recover ONLY when exactly one
971
- // chip in the document now matches trigger+value: with duplicates,
972
- // guessing risks silently editing the wrong instance, which is
973
- // worse than the no-op this falls back to.
974
- const atIndex = segments[editing.segIndex]
975
- const stillThere =
976
- atIndex?.type === 'chip' &&
977
- atIndex.trigger === editing.chip.trigger &&
978
- atIndex.value === editing.chip.value
979
- const segIdx = stillThere
980
- ? editing.segIndex
981
- : (() => {
982
- const matches: number[] = []
983
- segments.forEach((seg, i) => {
984
- if (
985
- seg.type === 'chip' &&
986
- seg.trigger === editing.chip.trigger &&
987
- seg.value === editing.chip.value
988
- ) {
989
- matches.push(i)
990
- }
991
- })
992
- return matches.length === 1 ? matches[0] : -1
993
- })()
994
- const oldChip = segIdx !== -1 ? segments[segIdx] : undefined
995
-
996
- if (oldChip?.type === 'chip') {
997
- const newChip: ChipSegment = {
998
- type: 'chip',
999
- trigger: activeTrigger.config.char,
1000
- ...chipData,
1001
- }
1002
- let newSegments = segments.map((seg, i) => (i === segIdx ? newChip : seg))
1003
-
1004
- // Guarantee a real landing spot after the replaced chip, mirroring
1005
- // resolveChip's trailing-space convention (prompt-area-engine.ts):
1006
- // if the new chip is now the last segment (or directly followed by
1007
- // another chip), the caret would land at a bare element boundary
1008
- // with no text node, which some engines fail to render/snap a
1009
- // visible caret at.
1010
- const nextSeg = newSegments[segIdx + 1]
1011
- const insertedSpace = !nextSeg || nextSeg.type !== 'text' || nextSeg.text.length === 0
1012
- if (insertedSpace) {
1013
- newSegments = [
1014
- ...newSegments.slice(0, segIdx + 1),
1015
- { type: 'text', text: ' ' },
1016
- ...newSegments.slice(segIdx + 1),
1017
- ]
1018
- }
1019
-
1020
- events.pushUndo(segments)
1021
- onChange(newSegments)
1022
- renderSegmentsToDOM(newSegments)
1023
-
1024
- // Same value + display text + data: treat as a no-op confirmation
1025
- // rather than a destructive delete+add — onChipDelete is
1026
- // documented as firing on backspace/forward-delete, not on
1027
- // re-confirming the already-selected suggestion.
1028
- const unchanged =
1029
- oldChip.value === newChip.value &&
1030
- oldChip.displayText === newChip.displayText &&
1031
- safeJsonStringify(oldChip.data) === safeJsonStringify(newChip.data)
1032
- if (!unchanged) {
1033
- onChipDelete?.(oldChip)
1034
- onChipAdd?.(newChip)
1035
- }
1036
-
1037
- // +1 when a space was inserted, matching resolveChip's own
1038
- // "+1 accounts for the trailing space after the chip" placement —
1039
- // landing exactly at the chip's end would put the caret at the
1040
- // same bare element boundary the inserted space exists to avoid.
1041
- const caretOffset =
1042
- segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)
1043
- setCursorAtOffset(editor, caretOffset)
1044
- }
1045
- }
1046
-
608
+ // Chip-click dropdown (`reopenOnChipClick`): the whole path lives in
609
+ // use-chip-editing.ts; on true the chip was replaced in place (or the edit
610
+ // was refused because the composer went disabled) and we only dismiss and
611
+ // refocus here.
612
+ if (chipEditing.trySelectEditingChip(segments, chipData, activeTrigger.config.char, events.pushUndo)) {
1047
613
  dismissTrigger()
1048
614
  setTimeout(() => {
1049
615
  editorRef.current?.focus()
1050
616
  }, 0)
1051
617
  return
1052
618
  }
1053
-
1054
619
  const result = resolveChip(segments, activeTrigger, chipData)
1055
620
 
1056
621
  onChange(result.segments)
@@ -1062,7 +627,6 @@ export function usePromptArea({
1062
627
  ...chipData,
1063
628
  })
1064
629
 
1065
- // Position cursor after the chip + trailing space
1066
630
  const editor = editorRef.current
1067
631
  if (editor) {
1068
632
  setCursorAtOffset(editor, result.cursorOffset)
@@ -1070,348 +634,48 @@ export function usePromptArea({
1070
634
 
1071
635
  dismissTrigger()
1072
636
 
1073
- // Refocus editor after popover interaction
1074
637
  setTimeout(() => {
1075
638
  editorRef.current?.focus()
1076
639
  }, 0)
1077
640
  },
1078
- [
1079
- activeTrigger,
1080
- readSegmentsFromDOM,
1081
- onChange,
1082
- renderSegmentsToDOM,
1083
- dismissTrigger,
1084
- onChipAdd,
1085
- onChipDelete,
1086
- events,
1087
- disabled,
1088
- ],
641
+ [activeTrigger, readSegmentsFromDOM, onChange, renderSegmentsToDOM, dismissTrigger, onChipAdd, events, chipEditing],
1089
642
  )
1090
643
 
1091
644
  const selectSuggestion = selectSuggestionInternal
1092
645
 
1093
- // Chip-click dropdown: once the empty-query suggestions arrive, preselect
1094
- // the chip's current value so the list opens "on" the existing choice.
1095
- useEffect(() => {
1096
- const editing = editingChip.current
1097
- if (!editing || !activeTrigger?.config.reopenOnChipClick) return
1098
- const idx = suggestions.findIndex((s) => s.value === editing.chip.value)
1099
- if (idx > 0) setSelectedSuggestionIndex(idx)
1100
- }, [suggestions, activeTrigger])
1101
-
1102
- // -----------------------------------------------------------------------
1103
- // Handle key events
1104
- // -----------------------------------------------------------------------
1105
-
1106
- const handleKeyDown = useCallback(
1107
- (e: React.KeyboardEvent<HTMLDivElement>) => {
1108
- const applyEditResult = (
1109
- editor: HTMLDivElement,
1110
- result: { segments: Segment[]; cursorOffset: number },
1111
- ) => {
1112
- // Ordered-list numbers are a projection of position: rebuild them on
1113
- // every structural edit and remap the caret across any digit-run width
1114
- // changes. No-op (same reference) when there are no ordered lists.
1115
- let { segments, cursorOffset } = result
1116
- if (markdownEnabled) {
1117
- const renumbered = renumberOrderedListSegments(segments)
1118
- segments = renumbered.segments
1119
- cursorOffset = remapOffset(cursorOffset, renumbered.edits)
1120
- }
1121
- lastRenderedValue.current = segments
1122
- onChange(segments)
1123
- renderSegmentsToDOM(segments)
1124
- setCursorAtOffset(editor, cursorOffset)
1125
- }
1126
-
1127
- const tryListContinuation = (editor: HTMLDivElement): boolean => {
1128
- if (!markdownEnabled) return false
1129
- const segments = readSegmentsFromDOM()
1130
- const cursorPos = getCursorOffset(editor)
1131
- if (cursorPos === null) return false
1132
- const plainText = segmentsToPlainText(segments)
1133
- if (!getListContext(plainText, cursorPos)) return false
1134
- const result = insertListContinuation(segments, cursorPos)
1135
- if (result) applyEditResult(editor, result)
1136
- return true
1137
- }
1138
-
1139
- // 1. Flush pending undo debounce so Cmd+Z has the latest checkpoint
1140
- if ((e.metaKey || e.ctrlKey) && e.key === 'z' && undoBaseState.current) {
1141
- if (undoTimer.current) {
1142
- clearTimeout(undoTimer.current)
1143
- undoTimer.current = null
1144
- }
1145
- events.pushUndo(undoBaseState.current)
1146
- undoBaseState.current = null
1147
- }
1148
-
1149
- // 1a. Undo/redo intercept
1150
- if (events.handleKeyDownForUndoRedo(e)) return
1151
-
1152
- // 1.5 Markdown formatting shortcuts (Cmd+B bold, Cmd+I italic)
1153
- if (
1154
- markdownEnabled &&
1155
- (e.metaKey || e.ctrlKey) &&
1156
- !e.shiftKey &&
1157
- (e.key === 'b' || e.key === 'i')
1158
- ) {
1159
- e.preventDefault()
1160
- const editor = editorRef.current
1161
- if (!editor) return
1162
-
1163
- const offsets = getSelectionOffsets(editor)
1164
- if (!offsets || offsets.start === offsets.end) return
1165
-
1166
- const marker = e.key === 'b' ? '**' : '*'
1167
- const currentSegments = readSegmentsFromDOM()
1168
- events.pushUndo(currentSegments)
1169
-
1170
- const result = toggleMarkdownWrap(currentSegments, offsets.start, offsets.end, marker)
1171
- if (!result) return
1172
-
1173
- lastRenderedValue.current = result.segments
1174
- onChange(result.segments)
1175
- renderSegmentsToDOM(result.segments)
1176
- setSelectionAtOffsets(editor, result.selectionStart, result.selectionEnd)
1177
- return
1178
- }
1179
-
1180
- // 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
1181
- // keydown and suppresses the char so it never enters the editor — for
1182
- // opening an external surface (dialog, palette). The DOM read is gated on
1183
- // the typed key actually matching a launch char, so it stays off the hot
1184
- // path. insertChip still inserts a chip at the cursor if the consumer
1185
- // wants one after the external selection.
1186
- if (
1187
- !e.metaKey &&
1188
- !e.ctrlKey &&
1189
- !e.altKey &&
1190
- !e.nativeEvent.isComposing &&
1191
- e.key.length === 1
1192
- ) {
1193
- const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)
1194
- const editor = editorRef.current
1195
- if (launcher?.onActivate && editor) {
1196
- const cursorPos = getCursorOffset(editor)
1197
- if (cursorPos !== null) {
1198
- const segments = readSegmentsFromDOM()
1199
- const plainText = segmentsToPlainText(segments)
1200
- if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {
1201
- e.preventDefault()
1202
- launcher.onActivate({
1203
- text: plainText,
1204
- cursorPosition: cursorPos,
1205
- insertChip: buildInsertChip(
1206
- replaceTextRange(segments, cursorPos, cursorPos, launcher.char),
1207
- { config: launcher, startOffset: cursorPos, query: '' },
1208
- ),
1209
- })
1210
- return
1211
- }
1212
- }
1213
- }
1214
- }
1215
-
1216
- // 2. Trigger dropdown navigation. Gated on the dropdown actually being
1217
- // ON SCREEN, which matches TriggerPopover's own render condition
1218
- // (non-empty suggestions, OR loading/error/emptyMessage) rather than
1219
- // just `suggestions.length > 0` — otherwise a popover left open in a
1220
- // loading/empty state (e.g. right after a chip-click reopen, before its
1221
- // empty-query search resolves) lets Enter fall through to onSubmit and
1222
- // Escape fall through to onEscape while still visibly on screen.
1223
- const dropdownVisible =
1224
- activeTrigger &&
1225
- activeTrigger.config.mode === 'dropdown' &&
1226
- (suggestions.length > 0 ||
1227
- suggestionsLoading ||
1228
- suggestionsError !== null ||
1229
- !!activeTrigger.config.emptyMessage)
1230
- if (dropdownVisible) {
1231
- if (e.key === 'ArrowDown') {
1232
- e.preventDefault()
1233
- if (suggestions.length > 0) {
1234
- setSelectedSuggestionIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
1235
- }
1236
- return
1237
- }
1238
- if (e.key === 'ArrowUp') {
1239
- e.preventDefault()
1240
- if (suggestions.length > 0) {
1241
- setSelectedSuggestionIndex((prev) => Math.max(prev - 1, 0))
1242
- }
1243
- return
1244
- }
1245
- if (e.key === 'Enter' || e.key === 'Tab') {
1246
- e.preventDefault()
1247
- const selected = suggestions[selectedSuggestionIndex]
1248
- if (selected) {
1249
- selectSuggestionInternal(selected)
1250
- }
1251
- return
1252
- }
1253
- if (e.key === 'Escape') {
1254
- e.preventDefault()
1255
- dismissTrigger()
1256
- return
1257
- }
1258
- }
1259
-
1260
- // 2.5. Auto-resolve on Space when trigger has resolveOnSpace
1261
- if (e.key === ' ' && activeTrigger && activeTrigger.config.resolveOnSpace) {
1262
- const query = activeTrigger.query.trim()
1263
- if (query.length > 0) {
1264
- e.preventDefault()
1265
- autoResolveActiveTrigger(activeTrigger)
1266
- return
1267
- }
1268
- }
646
+ // Handle key events the numbered router and its chip-deletion helpers live in
647
+ // use-prompt-area-keydown.ts.
1269
648
 
1270
- // 2.6. Tab/Shift+Tab for list indentation (only when trigger dropdown is NOT open)
1271
- if (markdownEnabled && e.key === 'Tab' && !activeTrigger) {
1272
- const editor = editorRef.current
1273
- if (editor) {
1274
- const segments = readSegmentsFromDOM()
1275
- const plainText = segmentsToPlainText(segments)
1276
- const cursorPos = getCursorOffset(editor)
1277
- if (cursorPos !== null) {
1278
- const ctx = getListContext(plainText, cursorPos)
1279
- if (ctx) {
1280
- e.preventDefault()
1281
- const result = e.shiftKey
1282
- ? outdentListItem(segments, cursorPos)
1283
- : indentListItem(segments, cursorPos)
1284
- if (result) applyEditResult(editor, result)
1285
- return
1286
- }
1287
- }
1288
- }
1289
- }
1290
-
1291
- // Insert a newline at the model level (avoids the browser's broken
1292
- // contentEditable behaviour near <a> elements).
1293
- const insertPlainNewline = (editor: HTMLDivElement): void => {
1294
- const offsets = getSelectionOffsets(editor)
1295
- if (!offsets) return
1296
- const currentSegments = readSegmentsFromDOM()
1297
- events.pushUndo(currentSegments)
1298
- const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '\n')
1299
- applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start + 1 })
1300
- }
1301
-
1302
- // 2.8 Shift+Enter always inserts a newline (after a list-continuation check).
1303
- if (e.key === 'Enter' && e.shiftKey && !e.nativeEvent.isComposing) {
1304
- e.preventDefault()
1305
- const editor = editorRef.current
1306
- if (editor && !tryListContinuation(editor)) insertPlainNewline(editor)
1307
- return
1308
- }
1309
-
1310
- // 3. Enter without Shift (skipping IME): submit when `submitOnEnter` is
1311
- // set, else insert a newline — continuing a list if we are in one.
1312
- //
1313
- // Under `submitOnEnter`, Enter submits *unconditionally*. It used to try
1314
- // a list continuation first, which meant that once you had typed a
1315
- // bullet, the send key silently stopped sending: every Enter added
1316
- // another empty bullet, and the only way out was to clear the list. Enter
1317
- // is the send key in a chat composer, and a key that does something else
1318
- // depending on what the line above happens to start with is not one.
1319
- // Continuation lives on Shift+Enter (branch 2.8), which is the newline
1320
- // key here and therefore where "newline, and keep the list going"
1321
- // belongs. Without `submitOnEnter`, Enter *is* the newline key, so it
1322
- // keeps the continuation.
1323
- if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
1324
- e.preventDefault()
1325
- if (submitOnEnter) {
1326
- onSubmit?.(readSegmentsFromDOM())
1327
- return
1328
- }
1329
- const editor = editorRef.current
1330
- if (editor && !tryListContinuation(editor)) insertPlainNewline(editor)
1331
- return
1332
- }
1333
-
1334
- // 4. Escape
1335
- if (e.key === 'Escape' && onEscape) {
1336
- onEscape()
1337
- return
1338
- }
1339
-
1340
- // 4.5 Non-collapsed selection delete (Backspace/Delete across <a> boundaries)
1341
- if ((e.key === 'Backspace' || e.key === 'Delete') && !e.nativeEvent.isComposing) {
1342
- const editor = editorRef.current
1343
- if (editor) {
1344
- const offsets = getSelectionOffsets(editor)
1345
- if (offsets && offsets.start !== offsets.end) {
1346
- e.preventDefault()
1347
- const currentSegments = readSegmentsFromDOM()
1348
- events.pushUndo(currentSegments)
1349
- const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '')
1350
- applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start })
1351
- runTriggerDetection()
1352
- return
1353
- }
1354
- }
1355
- }
1356
-
1357
- // 5. Backspace: check list prefix removal, then chip deletion
1358
- if (e.key === 'Backspace') {
1359
- const editor = editorRef.current
1360
- if (editor) {
1361
- const segments = readSegmentsFromDOM()
1362
- const cursorPos = getCursorOffset(editor)
1363
- if (markdownEnabled && cursorPos !== null) {
1364
- const result = removeListPrefix(segments, cursorPos)
1365
- if (result) {
1366
- e.preventDefault()
1367
- applyEditResult(editor, result)
1368
- runTriggerDetection()
1369
- return
1370
- }
1371
- }
1372
- }
1373
- if (handleChipBackspace()) {
1374
- e.preventDefault()
1375
- runTriggerDetection()
1376
- return
1377
- }
1378
- }
1379
-
1380
- // 6. Delete (forward): delete chip as whole unit
1381
- if (e.key === 'Delete' && handleChipForwardDelete()) {
1382
- e.preventDefault()
1383
- runTriggerDetection()
1384
- return
1385
- }
1386
- },
1387
- [
1388
- activeTrigger,
1389
- suggestions,
1390
- suggestionsLoading,
1391
- suggestionsError,
1392
- selectedSuggestionIndex,
1393
- onSubmit,
1394
- submitOnEnter,
1395
- onEscape,
1396
- readSegmentsFromDOM,
1397
- onChange,
1398
- renderSegmentsToDOM,
1399
- markdownEnabled,
1400
- dismissTrigger,
1401
- handleChipBackspace,
1402
- handleChipForwardDelete,
1403
- autoResolveActiveTrigger,
1404
- runTriggerDetection,
1405
- selectSuggestionInternal,
1406
- events,
1407
- triggers,
1408
- buildInsertChip,
1409
- ],
1410
- )
649
+ const handleKeyDown = usePromptAreaKeydown({
650
+ editorRef,
651
+ triggers,
652
+ markdownEnabled,
653
+ submitOnEnter,
654
+ onSubmit,
655
+ onEscape,
656
+ onChange,
657
+ onChipAdd,
658
+ onChipDelete,
659
+ readSegmentsFromDOM,
660
+ renderSegmentsToDOM,
661
+ runTriggerDetection,
662
+ dismissTrigger,
663
+ buildInsertChip,
664
+ selectSuggestionInternal,
665
+ activeTrigger,
666
+ suggestions,
667
+ suggestionsLoading,
668
+ suggestionsError,
669
+ selectedSuggestionIndex,
670
+ setSelectedSuggestionIndex,
671
+ pushUndo: events.pushUndo,
672
+ handleKeyDownForUndoRedo: events.handleKeyDownForUndoRedo,
673
+ lastRenderedValue,
674
+ undoTimer,
675
+ undoBaseState,
676
+ })
1411
677
 
1412
- // -----------------------------------------------------------------------
1413
678
  // Imperative handle (memoized to avoid identity changes)
1414
- // -----------------------------------------------------------------------
1415
679
 
1416
680
  const handle: PromptAreaHandle = useMemo(
1417
681
  () => ({
@@ -1430,7 +694,9 @@ export function usePromptArea({
1430
694
  onChange([])
1431
695
  const editor = editorRef.current
1432
696
  if (editor) {
1433
- while (editor.firstChild) editor.removeChild(editor.firstChild)
697
+ while (editor.firstChild) {
698
+ editor.removeChild(editor.firstChild)
699
+ }
1434
700
  }
1435
701
  events.resetUndoHistory()
1436
702
  if (undoTimer.current) {
@@ -1445,7 +711,9 @@ export function usePromptArea({
1445
711
  onChange(segments)
1446
712
  renderSegmentsToDOM(segments)
1447
713
  const editor = editorRef.current
1448
- if (editor) setCursorAtOffset(editor, text.length)
714
+ if (editor) {
715
+ setCursorAtOffset(editor, text.length)
716
+ }
1449
717
  },
1450
718
  appendText: (text) => {
1451
719
  const segments = readSegmentsFromDOM()
@@ -1460,7 +728,9 @@ export function usePromptArea({
1460
728
  onChange(next)
1461
729
  renderSegmentsToDOM(next)
1462
730
  const editor = editorRef.current
1463
- if (editor) setCursorAtOffset(editor, segmentsToPlainText(next).length)
731
+ if (editor) {
732
+ setCursorAtOffset(editor, segmentsToPlainText(next).length)
733
+ }
1464
734
  },
1465
735
  getCursorPosition: () => {
1466
736
  const editor = editorRef.current
@@ -1468,11 +738,15 @@ export function usePromptArea({
1468
738
  },
1469
739
  setCursorPosition: (offset) => {
1470
740
  const editor = editorRef.current
1471
- if (editor) setCursorAtOffset(editor, offset)
741
+ if (editor) {
742
+ setCursorAtOffset(editor, offset)
743
+ }
1472
744
  },
1473
745
  setCursorToEnd: () => {
1474
746
  const editor = editorRef.current
1475
- if (editor) setCursorAtOffset(editor, segmentsToPlainText(readSegmentsFromDOM()).length)
747
+ if (editor) {
748
+ setCursorAtOffset(editor, segmentsToPlainText(readSegmentsFromDOM()).length)
749
+ }
1476
750
  },
1477
751
  getSelection: () => {
1478
752
  const editor = editorRef.current
@@ -1480,15 +754,15 @@ export function usePromptArea({
1480
754
  },
1481
755
  setSelection: (start, end) => {
1482
756
  const editor = editorRef.current
1483
- if (editor) setSelectionAtOffsets(editor, start, end)
757
+ if (editor) {
758
+ setSelectionAtOffsets(editor, start, end)
759
+ }
1484
760
  },
1485
761
  }),
1486
762
  [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipAdd, events],
1487
763
  )
1488
764
 
1489
- // -----------------------------------------------------------------------
1490
765
  // Compose event handlers
1491
- // -----------------------------------------------------------------------
1492
766
 
1493
767
  const eventHandlers = useMemo(
1494
768
  () => ({
@@ -1522,8 +796,8 @@ export function usePromptArea({
1522
796
  selectedSuggestionIndex,
1523
797
  handleInput,
1524
798
  handleKeyDown,
1525
- handleClick,
1526
- handleMouseDown,
799
+ handleClick: chipEditing.handleClick,
800
+ handleMouseDown: chipEditing.handleMouseDown,
1527
801
  selectSuggestion,
1528
802
  dismissTrigger,
1529
803
  handle,