From 005b2e61b03d614ec9882a2dcf643c963ffb935f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 27 Jul 2026 22:21:38 +0300 Subject: [PATCH] Composer: CodeMirror editor, unified prompt language, ChatInput decomposition (#2419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ui): unify composer @mention grammar Extract the composer's @mention rule into composer/language/mentions.ts and route highlighting, send-time extraction, backspace-deletes-the-mention and the file-path check through it. The rule previously lived as four separate regexes in ChatInput.tsx with divergent cleanup, so every new reference type had to be taught to all four. Bracket handling is now symmetric: [ and { were accepted before @ but ] and } were not stripped from the tail, so [@plan] resolved to the name 'plan]'. Add characterization tests for the markdown tokenizer, which had none, to pin current behavior ahead of the editor migration. * refactor(ui): unify composer slash, snippet and trigger grammar Extract /skill, /command and #snippet scanning into composer/language/prefixTokens.ts, and the rule deciding which autocomplete a caret asks for into composer/language/triggers.ts. Each sigil previously had three separate implementations: one for highlighting, one for send-time collection, and one for opening the picker. They disagreed on the valid character set — the send-time skill scanner accepted only lowercase names, so a /My_Skill token was painted as a command but never collected. Scanning is now generous and membership in the command, skill or snippet registry is the authority. resolveAutocompleteTrigger replaces the 90-line branch chain in updateAutocompleteState with a pure function, keeping the previous command > skill > snippet > mention precedence. * refactor(ui): tokenize the composer in a single pass Add composer/language/tokenize.ts as the one entry point producing every highlight range from the text plus what the composer knows about the workspace. It replaces six independent memos in ChatInput.tsx that each re-scanned the same string for markdown, fenced code, mentions, slash tokens, snippet tokens and attachment citations. Mentions now distinguish the reference span from the raw token: 'see @a/b.ts,' highlights @a/b.ts and leaves the comma as sentence punctuation, while backspace still deletes the whole token so a wrapping bracket is not orphaned. * feat(ui): add the CodeMirror composer editor Add composer/editor: a CodeMirror view that renders the prompt language as mark decorations, and a controlled React primitive around it. The composer previously painted a transparent textarea over a mirror div, which restricted highlighting to styles that do not change glyph advance width -- so bold and italic were impossible and the overlay was disabled outright on mobile, where wrapped text drifted from the caret anyway. CodeMirror owns the text and the caret together, removing the second layer. The document stays a plain string, so nothing downstream has to serialize a rich model back into a prompt. Split resolveHighlightSegments out of buildHighlightParts so the mirror overlay and the editor decorations share one priority resolution. Not yet wired into ChatInput. Editor rendering is unverified: the package has no DOM test environment, so only the state-level extension is covered. * refactor(ui): move the composer onto the CodeMirror editor Replace the transparent-textarea-over-mirror-div composer with ComposerEditor. The mirror is gone, and with it the constraint that highlighting may only use styles which do not change glyph advance width, and the mobile carve-out that disabled highlighting entirely because wrapped text drifted from the caret. Removals the editor makes unnecessary: - measureCaretInTextarea, 46 lines of hand-built text mirroring for popup placement, replaced by the editor reporting caret coordinates - adjustTextareaHeight and its two layout effects, replaced by the editor sizing itself; the dictation transcript height is now an explicit floor - getInsertedTextFromChange, which diffed old against new text to recover what a paste inserted; the editor reports the change directly - the highlight mirror element, its scroll-sync and its parts memo - a _commandMetadata property stashed on the textarea and never read Caret placement no longer needs a requestAnimationFrame after a text edit: text and selection travel in one transaction. Runtime behavior is unverified -- the package has no DOM test environment. Type-check, lint, the 295 chat tests and a production web build pass. * refactor(ui): extract composer text and path helpers Move the composer's text-splicing rules to composer/text.ts and its path handling to composer/attachments/filePaths.ts, with tests. None of this logic was covered before, despite handling VS Code drop payloads, percent-encoded file URIs and Windows drive letters. normalizeDroppedPath and toProjectRelativeMentionPath were useCallbacks that closed over nothing but their argument and the search directory; they are now plain functions taking the root explicitly. * refactor(ui): make the composer's slash commands a table Nine of the composer's local commands did the same thing -- render a visible magic prompt plus synthetic instructions and send them as one message -- and that shape was written out nine times as an else-if chain. Adding a command meant copying twenty lines and remembering to change every string. composer/submit/slashCommands.ts holds the commands as data and the shape as one executor; ChatInput keeps only the five that manipulate session state or open UI. The command names, prompts and failure toasts are now typed against the magic-prompt and i18n key unions, so a mistyped key fails to compile rather than failing at runtime. Net: 257 lines of branching become 68. * refactor(ui): move the composer's footer components out of ChatInput RevertedMessageDock, ComposerAttachmentControls, PermissionAutoAcceptButton, FocusModeButton and ComposerActionButtons each own a piece of the composer chrome and were defined inline above the 4500-line component. They now live under composer/ui with the imports they actually need. Pure moves; getRevertedPreview travels with the dock, its only caller. * refactor(ui): extract composer draft persistence Move the draft lifecycle -- identity switching, debounced writes, external deletion, and the flush-on-hide/freeze/pagehide edges -- into composer/state/useComposerDraft. It was seven interleaved effects and five refs sharing state through the component body, which made the ordering constraints between them (skip the next debounced write while restoring; record the empty signature before a queued write can resurrect a deleted draft) invisible. They are now stated where they apply. * refactor(ui): extract drop payload inspection hasDraggedFiles, collectDroppedFiles and collectDroppedFileUris were useCallbacks with empty dependency arrays -- pure DataTransfer readers wearing React clothing. They move to composer/attachments/dataTransfer.ts with tests covering the host differences they exist for: browser File lists, VS Code's proprietary tree types, OpenChamber's own internal-drag marker, and getData throwing during dragover. * refactor(ui): extract the mobile composer shell Move the pill state machine into composer/state/useMobileComposerShell and the visual-viewport pinning into composer/state/useMobileViewportPin. Between them they held eight refs, four pieces of state and eleven effects interleaved with the rest of the composer, which hid what they are: not one state machine but a state machine plus a set of corrections for specific platform behaviors -- mobile browsers dismissing the keyboard before a tap's click lands, iOS refusing programmatic focus outside a gesture, WebKit leaving the layout viewport panned after the keyboard hides, overlay chains handing off through a frame where nothing is open. Verbatim moves: every timeout, flushSync and guard keeps its value and its reason, because none of them is verifiable outside a real device. * refactor(ui): extract outgoing message assembly A single send can carry queued messages, the composer text, inline review comments, resolved @file attachments, a linked issue or PR, synthetic parts from conflict resolution, and a skills instruction -- all flattened into OpenCode's one-primary-plus-parts shape. The flattening rules were spread through handleSubmit with no coverage, so the ordering they encode (oldest queued message becomes primary; inline comments attach to the last authored body, not a new part; PR instructions precede the diff) was trusted rather than checked. buildOutgoingMessage is a pure function over injected resolvers, with 25 tests covering that ordering. * refactor(ui): extract new-session draft targeting Move project and worktree selection for the new-session draft into composer/state/useDraftTarget. The rule worth naming is that the draft can point at a directory that does not exist yet -- a worktree still being created. It has to survive not appearing in the branch list, or the selector snaps back to the project root mid-creation and the session starts in the wrong place. * refactor(ui): extract composer context chips and linked reference rows The chips standing for attached-but-not-typed context (review comments, dev server logs, preview annotations, terminal selections) were four near-identical inline blocks; three of them collapse into one CountChip. The linked issue and linked PR rows were 100 lines of duplicated markup differing only in their number label, their branch line and which picker they reopen. They are now one LinkedReferenceRow. * refactor(ui): extract draft target selectors Move the project and branch pickers into composer/ui/DraftTargetSelectors: inline selects for desktop, trigger buttons and bottom sheets for mobile, all rendering the same options from useDraftTarget. The project label -- custom icon image, configured icon, or a folder fallback, with the project name -- was a useCallback rendering JSX and used from four places; it is now a ProjectLabel component. * refactor(ui): extract the collapsed mobile pill composer Move the pill into composer/ui/MobilePillComposer. Both places that ask dictation to start now go through one toggleDictation callback rather than dispatching the global event inline. * refactor(ui): extract the composer footer Move the footer row into composer/ui/ComposerFooter, which now owns the desktop and mobile layouts and the components they place. ChatInput keeps only the handlers it passes in. * refactor(ui): collapse the composer's four autocomplete states into one The composer tracked each picker with its own show flag and query string, which encoded 'exactly one is open' as four booleans that had to be kept mutually exclusive by hand. It is now one openAutocomplete kind plus one query, which is what resolveAutocompleteTrigger already returns. The four popup blocks -- identical apart from their component and caret width -- become ComposerAutocompletePopups. * refactor(ui): extract autocomplete positioning and message history useAutocompletePosition owns caret-relative popup placement, which only applies in focus mode. useMessageHistory owns arrow-key recall. Its transitions are pure functions with tests: entering history stashes the draft exactly once, so walking back several messages and returning still restores what the user actually typed rather than the last recalled message. * docs: document the composer module Record what each layer owns and the invariants that are not visible from the code: that the prompt language is the single source of truth for syntax, which ordering rules in the submit assembly and draft lifecycle are load-bearing, that the mobile hooks are platform corrections rather than state machines, and that rendering, focus, keyboard and WKWebView behavior are not covered by tests and must be verified by hand. * fix(ui): restore the composer caret colour and click-to-focus Two regressions from the editor migration. The caret rendered black in dark themes. CodeMirror's base theme hard-codes it through '.cm-editor.cm-light .cm-content', one class more specific than the plain '.cm-content' rule the composer theme used, so the base theme won. Matching that specificity with '&.cm-editor' fixes both variants. Clicking the composer's empty space no longer focused it. A textarea filled its box, so the browser placed the caret for any click inside it; CodeMirror's content element covers only the text. The content box now stretches to the full editor height, and clicks landing outside it — in the composer's padding — are forwarded to the nearest text position. The theme moves to its own module with a test that installs it. EditorView.theme compiles selectors at import and throws on scopes it was not given, including '&light' and '&dark'; neither the build nor the type-check catches that, and the failure takes the whole composer down at runtime. * fix(ui): colour the composer caret where it is actually drawn The previous fix styled caret-color, which drawSelection() overrides with 'transparent !important' at the highest precedence -- it hides the native caret and draws its own .cm-cursor element, whose base style is a hard-coded 'border-left: 1.2px solid black'. So the caret stayed black on dark themes. CodeMirror recolours that cursor only for editors that declare themselves dark. OpenChamber themes are not merely light or dark, so the cursor takes the surface foreground directly instead. The theme spec is exported and asserted against: the caret rule must target .cm-cursor, must not style caret-color, and must carry enough specificity to beat CodeMirror's own &dark override. * feat(ui): add emphasis, attention and path highlighting to the composer The constructs the editor migration was for. - **bold** and *italic* render as real weight and slant. They are additive styles: a segment carries one class string, so choosing between weight and colour would lose one of them -- bold inside a heading now keeps the heading colour and gains weight. - '!!! ' marks an attention line. Three marks, so a sentence ending in '!!' is not swallowed. - '~path' highlights a path without attaching it, unlike '@path'. Inert by design: it feeds neither the autocomplete nor the send path. False positives are excluded positionally rather than by character, since these delimiters are ordinary prose: '2 * 3' and 'foo_bar' are not emphasis, '~approximately' and '~1.2 seconds' are not paths. Also fix the expanded composer, which kept the collapsed composer's eight-line height cap: the editor scrolled inside an invisible window while the rest of the surface sat empty. * fix(ui): style the composer's selection instead of leaving CodeMirror's Selecting text rendered it in CodeMirror's stock lavender, which buried the token colours. drawSelection() paints its own layer and CodeMirror styles the focused case through a six-class selector; the composer's rule was three deep and lost. The tint is translucent rather than the flat selection token: an opaque selection hides the colours the composer exists to show, and selecting text here is for moving it, not for stopping reading it. Same failure shape as the caret, so the theme test now covers both. * fix(ui): mute the composer placeholder The placeholder rendered at full text brightness. Its colour referenced --surface-mutedForeground, but the theme emits --surface-muted-foreground: an unknown custom property makes the declaration invalid rather than falling back, and since color inherits, the placeholder simply took the editor's text colour while the source looked correct. The theme test now rejects camelCased tokens outright, since this failure is invisible in every check that does not render. * fix(ui): create the composer editor before the expand gesture ends The mobile pill expands with flushSync and focuses the editor on the very next line, still inside the tap's call stack, because that is the only way a mobile browser raises the keyboard. The EditorView was created in a passive effect, which flushSync makes no promise about — so at the moment focus() was called there was no view to focus. With a textarea the element existed as soon as flushSync returned, which is why this worked before the migration. Creating the view in a layout effect restores that ordering. * perf(ui): keep the composer editor alive across the mobile pill swap The pill and the full composer are different subtrees, so expanding or collapsing unmounted and rebuilt the editor. With a textarea that was one DOM node. A CodeMirror view is extensions, state, document, decorations and a first measure — all inside the tap's flushSync, before the browser is allowed to paint the swap. The shape change therefore landed late enough to look driven by the keyboard rather than by the tap. The view now lives in a store owned by ChatInput and is detached and re-attached instead of destroyed and rebuilt. Its extensions read callbacks through a ref held by the store, so a kept view always calls into the mounted instance; compartments move to module scope, since per-instance ones would be unknown to a reused view's configuration. Also restore the caret hold: WKWebView draws the caret as a native layer that ignores CSS transforms and visibly flies across the screen during the keyboard slide. The rule hiding it targeted textarea and input, which the composer is no longer — and its caret is now a drawn .cm-cursor element rather than the native one. * debug(ui): on-screen timeline for the mobile composer swap TEMPORARY, Capacitor-only. Two plausible fixes for the swap lagging behind the keyboard changed nothing, so the theory behind them was wrong. This overlay draws the event timeline straight onto the screen -- the tap, the committed swap, the first paints after it, the keyboard choreography, and whether the editor was created or re-attached -- so one screenshot replaces guessing. It doubles as an asset-freshness check: no overlay means the app runs a bundle from before this commit. * fix(ui): put the composer swap on glass before the keyboard moves The overlay timelines settled it. The swap itself was never slow: commit in 12ms, editor re-attach in 3ms, focus immediate. What lagged was presentation: WKWebView stops presenting web frames the moment focus starts the keyboard transition and holds the last presented frame until it ends. Focusing in the same task as the swap meant the last presented frame still showed the pill — paint-1 fired at 29ms, the next frame at 190ms, exactly when the keyboard was already moving. On expand, Capacitor now waits two frames before focusing, so the swapped composer is presented first and the keyboard rises under it. The Capacitor WebView raises the keyboard for a focus() outside the gesture task; mobile browsers do not, so they keep the synchronous path. The collapse direction had a genuine race, caught on one screenshot: the oc:keyboard-intent collapse arrives a few milliseconds after blur on a setTimeout(0), and React's scheduling of setFocused(false) can lose to it — busyRef stays stale, the intent handler skips the instant collapse, and the pill appears via the 250ms fallback, 370ms after the keyboard has gone. The Capacitor blur branch now commits the state with flushSync. The diagnostic overlay stays in until this is confirmed on device. * fix(ui): raise the keyboard from the swap's first frame Two frames of delay before focusing made expand visibly sequential: swap, then keyboard. Focusing inside the first frame after the commit puts the swap's frame into the rendering pipeline before the keyboard transaction starts, so the keyboard rises from the tap and the composer appears during the rise rather than after it. * fix(ui): restructure the draft screen in the same frame as the pill swap The draft screen centers its title over the space the composer leaves, and its starter chips leave when the keyboard is up. The chips were keyed on oc-keyboard-open, which lands with the keyboardWillShow bridge event ~100ms after the tap — so expanding the composer restructured the page twice: once at the swap (composer grows, title re-centers) and again mid-keyboard-rise (chips vanish, title re-centers again). Chat has no centered content, which is why it was already smooth and the draft screen was not. A root class now announces the expanded composer from a layout effect, in the same frame as the swap, and the chips key on it: one restructure, fused with the pill morph, before the keyboard moves. The keyboard classes remain as fallbacks for keyboard-up states that do not go through the pill. * debug(ui): remove the mobile swap timeline overlay The diagnostic did its job: it identified WKWebView's presentation pause during keyboard transitions, the React-scheduling race in the collapse path, and the draft screen's double restructure — all fixed and confirmed on device. * feat(ui): grow the mobile composer with content, drop the fullscreen handle The swipe-up handle promised a fullscreen composer but the normal eight-line cap already reached within a line of the same height, so the gesture bought almost nothing and cost a 28px bar above the editor. The composer now just grows with what is typed: a generous line cap plus a CSS ceiling of the space the keyboard actually leaves, whichever is smaller (the editor cap accepts both and takes min()). The handle, its swipe gestures and the shell's touch plumbing are gone. * fix(ui): measure the mobile composer ceiling instead of estimating it The 220px chrome constant guessed at what surrounds the editor. The old fullscreen handle guessed at nothing — it let flex distribute real space (and on Capacitor even that silently failed: its h-full resolved against a shrink-wrap parent, which is why the gesture bought almost nothing). The ceiling is now measured the way the handle meant to: the screen container is marked data-composer-bound, and the editor may grow until the composer fills it — chrome around the editor read live from the DOM, so attachment chips, the model row and keyboard resizes all shift the cap by themselves. * fix(ui): keep a 4px gap between the grown composer and the header On the chat screen the fully grown composer's border landed exactly on the header's bottom edge. The gap is a visual design choice, not another chrome estimate: the ceiling itself stays measured. * fix(ui): show iOS selection handles without giving up typing speed iOS pins its selection drag handles to the visible native selection and colours them from the caret, while drawSelection() hides both. Removing drawSelection(), or leaving the native caret visible while typing, both make iOS answer every keystroke with severe input lag. Touch devices now keep drawSelection() and layer a theme over it that re-shows the native selection, plus an .oc-native-range marker that enables the native caret only while a range is selected — when there is no caret to lag on. * feat(ui): make file mentions editable instead of atomic-delete Deleting a character inside an @file mention edited nothing and erased the whole token. Mentions now edit like /skill tokens: a deletion changes the text and the caret position reopens the file picker on its own. findMentionAt and MentionToken.rawEnd existed only for the atomic delete and are removed. * fix(ui): composer selection visibility and external-insert caret Selection was nearly invisible for two reasons: the tint was mixed down from --interactive-selection, which themes define with its own alpha (often under 10%); and the painted selection layer sits behind the content, so tokens with their own background (inline code, fences) covered it entirely. The native selection now shows on every device, not only touch — it paints over token backgrounds — and its tint comes from --primary at 25%, a full-strength colour in every theme. drawSelection() and the range-scoped native caret stay exactly as before, so typing keeps the lag-free path. External rewrites (add-to-chat, draft restore, history, dictation) also left the caret at its old position, so the next insertion landed inside the previous one. They now put the caret at the end, as the old textarea did, and pin the scroller to the bottom once the layout settles — a transaction-time scrollIntoView fires before the max-height cap exists and scrolls nothing. * fix(ui): render ***triple emphasis*** as bold italic The emphasis tokenizer capped delimiter runs at two characters, so ***x*** parsed as a stray asterisk plus an italic span. Runs of three now emit both a strong and an emphasis range over the same content; the two are additive styles, so they compose into bold italic. * fix: insert mentions through editor dispatch Places the caret immediately after an inserted mention Avoids rewriting the whole message and jumping the scroll to the bottom Falls back to appending inline text when no editor is available --- .../ui/src/components/chat/ChatContainer.tsx | 10 +- packages/ui/src/components/chat/ChatInput.tsx | 4223 +++-------------- .../chat/__tests__/composerHighlight.test.ts | 444 ++ .../components/chat/composer/DOCUMENTATION.md | 126 + .../chat/composer/__tests__/text.test.ts | 121 + .../__tests__/dataTransfer.test.ts | 137 + .../attachments/__tests__/filePaths.test.ts | 177 + .../chat/composer/attachments/dataTransfer.ts | 92 + .../chat/composer/attachments/filePaths.ts | 176 + .../chat/composer/editor/ComposerEditor.tsx | 525 ++ .../editor/__tests__/composerLanguage.test.ts | 107 + .../composer/editor/__tests__/theme.test.ts | 189 + .../chat/composer/editor/composerLanguage.ts | 97 + .../components/chat/composer/editor/theme.ts | 162 + .../chat/composer/editor/viewStore.ts | 32 + .../language/__tests__/mentions.test.ts | 155 + .../composer/language/__tests__/paths.test.ts | 88 + .../language/__tests__/prefixTokens.test.ts | 139 + .../language/__tests__/tokenize.test.ts | 176 + .../language/__tests__/triggers.test.ts | 127 + .../chat/composer/language/mentions.ts | 132 + .../chat/composer/language/paths.ts | 88 + .../chat/composer/language/prefixTokens.ts | 104 + .../chat/composer/language/tokenize.ts | 95 + .../chat/composer/language/triggers.ts | 118 + .../state/__tests__/useMessageHistory.test.ts | 113 + .../composer/state/useAutocompletePosition.ts | 100 + .../chat/composer/state/useComposerDraft.ts | 230 + .../chat/composer/state/useDraftTarget.ts | 298 ++ .../chat/composer/state/useMessageHistory.ts | 99 + .../composer/state/useMobileComposerShell.ts | 435 ++ .../composer/state/useMobileViewportPin.ts | 146 + .../__tests__/buildOutgoingMessage.test.ts | 248 + .../submit/__tests__/slashCommands.test.ts | 127 + .../composer/submit/buildOutgoingMessage.ts | 178 + .../chat/composer/submit/slashCommands.ts | 182 + .../ui/src/components/chat/composer/text.ts | 106 + .../composer/ui/ComposerActionButtons.tsx | 124 + .../ui/ComposerAttachmentControls.tsx | 142 + .../ui/ComposerAutocompletePopups.tsx | 125 + .../chat/composer/ui/ComposerContextChips.tsx | 137 + .../chat/composer/ui/ComposerFooter.tsx | 265 ++ .../chat/composer/ui/DraftTargetSelectors.tsx | 360 ++ .../chat/composer/ui/FocusModeButton.tsx | 53 + .../chat/composer/ui/LinkedReferenceRow.tsx | 96 + .../chat/composer/ui/MobilePillComposer.tsx | 152 + .../ui/PermissionAutoAcceptButton.tsx | 87 + .../chat/composer/ui/RevertedMessageDock.tsx | 181 + .../src/components/chat/composerHighlight.ts | 237 +- packages/ui/src/lib/ime.ts | 6 +- packages/ui/src/styles/mobile.css | 37 +- 51 files changed, 8433 insertions(+), 3671 deletions(-) create mode 100644 packages/ui/src/components/chat/__tests__/composerHighlight.test.ts create mode 100644 packages/ui/src/components/chat/composer/DOCUMENTATION.md create mode 100644 packages/ui/src/components/chat/composer/__tests__/text.test.ts create mode 100644 packages/ui/src/components/chat/composer/attachments/__tests__/dataTransfer.test.ts create mode 100644 packages/ui/src/components/chat/composer/attachments/__tests__/filePaths.test.ts create mode 100644 packages/ui/src/components/chat/composer/attachments/dataTransfer.ts create mode 100644 packages/ui/src/components/chat/composer/attachments/filePaths.ts create mode 100644 packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx create mode 100644 packages/ui/src/components/chat/composer/editor/__tests__/composerLanguage.test.ts create mode 100644 packages/ui/src/components/chat/composer/editor/__tests__/theme.test.ts create mode 100644 packages/ui/src/components/chat/composer/editor/composerLanguage.ts create mode 100644 packages/ui/src/components/chat/composer/editor/theme.ts create mode 100644 packages/ui/src/components/chat/composer/editor/viewStore.ts create mode 100644 packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts create mode 100644 packages/ui/src/components/chat/composer/language/__tests__/paths.test.ts create mode 100644 packages/ui/src/components/chat/composer/language/__tests__/prefixTokens.test.ts create mode 100644 packages/ui/src/components/chat/composer/language/__tests__/tokenize.test.ts create mode 100644 packages/ui/src/components/chat/composer/language/__tests__/triggers.test.ts create mode 100644 packages/ui/src/components/chat/composer/language/mentions.ts create mode 100644 packages/ui/src/components/chat/composer/language/paths.ts create mode 100644 packages/ui/src/components/chat/composer/language/prefixTokens.ts create mode 100644 packages/ui/src/components/chat/composer/language/tokenize.ts create mode 100644 packages/ui/src/components/chat/composer/language/triggers.ts create mode 100644 packages/ui/src/components/chat/composer/state/__tests__/useMessageHistory.test.ts create mode 100644 packages/ui/src/components/chat/composer/state/useAutocompletePosition.ts create mode 100644 packages/ui/src/components/chat/composer/state/useComposerDraft.ts create mode 100644 packages/ui/src/components/chat/composer/state/useDraftTarget.ts create mode 100644 packages/ui/src/components/chat/composer/state/useMessageHistory.ts create mode 100644 packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts create mode 100644 packages/ui/src/components/chat/composer/state/useMobileViewportPin.ts create mode 100644 packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts create mode 100644 packages/ui/src/components/chat/composer/submit/__tests__/slashCommands.test.ts create mode 100644 packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts create mode 100644 packages/ui/src/components/chat/composer/submit/slashCommands.ts create mode 100644 packages/ui/src/components/chat/composer/text.ts create mode 100644 packages/ui/src/components/chat/composer/ui/ComposerActionButtons.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/ComposerAutocompletePopups.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/LinkedReferenceRow.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/PermissionAutoAcceptButton.tsx create mode 100644 packages/ui/src/components/chat/composer/ui/RevertedMessageDock.tsx diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 7b4469be..30ff57fd 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -995,7 +995,7 @@ export const ChatContainer: React.FC = ({ active = true, aut // No transform on this root: it would become the containing block for // the fullscreen composer's position:fixed visual-viewport pinning in // mobile browsers (see ChatInput's composerFormRef effect). -
+
{useCompactDraftLayout && !isDesktopExpandedInput ? : null}
= ({ active = true, aut if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { if (sessionMessageLoadState.status === 'error') { return ( -
+
{returnToParentButton}
@@ -1041,7 +1041,7 @@ export const ChatContainer: React.FC = ({ active = true, aut ); } return ( -
+
{returnToParentButton}
= ({ active = true, aut return ( // No transform here either — same fixed-positioning constraint as the // draft branch above. -
+
{returnToParentButton}
= ({ active = true, aut } return ( -
+
{returnToParentButton} ): string => ( - `${text}\u0000${[...confirmedMentions].sort().join('\u0000')}` -); const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; -const VS_CODE_DROP_DATA_TYPES = [ - 'CodeFiles', - 'codefiles', - 'application/vnd.code.tree', - 'application/vnd.code.tree.explorer', - 'text/uri-list', - 'text/plain', -]; - const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { return file; @@ -150,76 +170,16 @@ const renameFileForAttachmentCitation = (file: File, filename: string): File => }); }; -const buildImagePasteInsertion = (pastedText: string, citationText: string): string => { - const text = pastedText; - if (!text) { - return citationText; - } - return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`; -}; - -const getInsertedTextFromChange = (previousValue: string, nextValue: string): string => { - if (previousValue === nextValue) { - return ''; - } - - let prefixLength = 0; - while ( - prefixLength < previousValue.length - && prefixLength < nextValue.length - && previousValue[prefixLength] === nextValue[prefixLength] - ) { - prefixLength += 1; - } - - let previousSuffix = previousValue.length; - let nextSuffix = nextValue.length; - while ( - previousSuffix > prefixLength - && nextSuffix > prefixLength - && previousValue[previousSuffix - 1] === nextValue[nextSuffix - 1] - ) { - previousSuffix -= 1; - nextSuffix -= 1; - } - - return nextValue.slice(prefixLength, nextSuffix); -}; - const getFileMentionInputSourceForInsertedText = (insertedText: string): FileMentionAutocompleteInputSource => ( insertedText.includes('@') ? 'paste' : 'manual' ); -const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => { - if (!content) { - return content; - } - - const needsLeadingSpace = before.length > 0 - && !/\s$/.test(before) - && !/^\s/.test(content) - && !/[([{]$/.test(before); - const needsTrailingSpace = after.length > 0 - && !/\s$/.test(content) - && !/^\s/.test(after) - && !/^[\])}.,;:!?]/.test(after); - - return `${needsLeadingSpace ? ' ' : ''}${content}${needsTrailingSpace ? ' ' : ''}`; -}; - -const collectInlineSkillMentions = (text: string, skillNames: Set): string[] => { - const mentions: string[] = []; - INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) { - const name = match[2] || ''; - if (!skillNames.has(name) || mentions.includes(name)) { - continue; - } - mentions.push(name); - } - return mentions; -}; +/** + * Skills the user named inline with `/name`. Matched against the registry's + * exact casing, since the name is echoed back to the model as a skill to load. + */ +const collectInlineSkillMentions = (text: string, skillNames: Set): string[] => + collectKnownTokenNames(text, '/', skillNames, 'exact'); const buildSkillMentionInstruction = (skillNames: string[]): string | null => { if (skillNames.length === 0) return null; @@ -231,160 +191,6 @@ const hasUserMessages = (sessionId: string, directory?: string) => { return getSyncMessages(sessionId, directory).some((message) => message.role === 'user'); }; -const getRevertedPreview = (parts: Part[], fallback: string): string => { - const text = parts - .filter((part) => part.type === 'text' && !isSyntheticPart(part)) - .map((part) => { - const record = part as Record; - return typeof record.text === 'string' - ? record.text - : typeof record.content === 'string' - ? record.content - : ''; - }) - .join('\n') - .replace(/\s+/g, ' ') - .trim(); - - if (text) return text; - const filePart = parts.find((part) => part.type === 'file') as (Part & { filename?: string }) | undefined; - return filePart?.filename ? `[${filePart.filename}]` : fallback; -}; - -const FILE_URI_PREFIX = 'file://'; - -const encodeFilePath = (filepath: string): string => { - let normalized = filepath.replace(/\\/g, '/'); - if (/^[A-Za-z]:/.test(normalized)) { - normalized = `/${normalized}`; - } - return normalized - .split('/') - .map((segment, index) => { - if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment; - return encodeURIComponent(segment); - }) - .join('/'); -}; - -const toServerFileUrl = (filepath: string): string => { - const normalized = filepath.replace(/\\/g, '/').trim(); - if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) { - return normalized; - } - return `file://${encodeFilePath(normalized)}`; -}; - -const isLikelyAbsolutePath = (value: string): boolean => ( - value.startsWith('/') - || value.startsWith('\\\\') - || /^[A-Za-z]:[\\/]/.test(value) -); - -const toLikelyFileDropReference = (value: string): string | null => { - const trimmed = value.trim().replace(/^['"]+|['"]+$/g, ''); - if (!trimmed) { - return null; - } - - if (/[\r\n]/.test(trimmed)) { - return null; - } - - if (trimmed.toLowerCase().startsWith(FILE_URI_PREFIX)) { - return trimmed; - } - - if (isLikelyAbsolutePath(trimmed)) { - return trimmed; - } - - return null; -}; - -const collectStringLeaves = (input: unknown, output: Set, depth = 0): void => { - if (depth > 6 || input == null) { - return; - } - - if (typeof input === 'string') { - output.add(input); - return; - } - - if (Array.isArray(input)) { - for (const item of input) { - collectStringLeaves(item, output, depth + 1); - } - return; - } - - if (typeof input !== 'object') { - return; - } - - for (const value of Object.values(input)) { - collectStringLeaves(value, output, depth + 1); - } -}; - -const parseDroppedFileReferences = (rawPayload: string): string[] => { - const extracted = new Set(); - - const addCandidatesFromText = (value: string): void => { - const direct = toLikelyFileDropReference(value); - if (direct) { - extracted.add(direct); - return; - } - - for (const line of value.split(/\r?\n/)) { - const candidate = toLikelyFileDropReference(line); - if (candidate) { - extracted.add(candidate); - } - } - }; - - addCandidatesFromText(rawPayload); - - try { - const parsed = JSON.parse(rawPayload) as unknown; - const leaves = new Set(); - collectStringLeaves(parsed, leaves); - for (const leaf of leaves) { - addCandidatesFromText(leaf); - } - } catch { - // Ignore non-JSON payloads. - } - - return Array.from(extracted); -}; - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const normalized = trimmed.replace(/\\/g, '/'); - if (normalized === '/') { - return '/'; - } - return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; -}; - -const getProjectDisplayLabel = (project: { label?: string; path: string }): string => { - const label = project.label?.trim(); - if (label) { - return label; - } - return formatDirectoryName(project.path); -}; - const renderDraftTitle = (title: string, projectLabel: string | null): React.ReactNode => { if (!projectLabel) return title; const projectIndex = title.indexOf(projectLabel); @@ -399,543 +205,17 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea ); }; -const getProjectIconColor = (projectColor?: string | null): string | undefined => { - if (!projectColor) { - return undefined; - } - return PROJECT_COLOR_MAP[projectColor] ?? undefined; -}; - const MemoModelControls = React.memo(ModelControls); const MemoComposerDictation = React.memo(ComposerDictation); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); const MemoStatusRow = React.memo(StatusRow); -type RevertedMessageDockProps = { - sessionId: string | null; - directory?: string; -}; - -const RevertedMessageDock: React.FC = React.memo(({ sessionId, directory }) => { - const { t } = useI18n(); - const revertToMessage = useSessionUIStore((s) => s.revertToMessage); - const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage); - const handleSlashRedo = useSessionUIStore((s) => s.handleSlashRedo); - const [restoringId, setRestoringId] = React.useState(null); - const [forkingId, setForkingId] = React.useState(null); - const [collapsed, setCollapsed] = React.useState(true); - const revertedStateRef = React.useRef(EMPTY_REVERTED_MESSAGE_DOCK_STATE); - const revertedState = useDirectorySync( - React.useCallback((state) => { - const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current); - revertedStateRef.current = next; - return next; - }, [sessionId]), - directory, - ); - const revertMessageID = revertedState.revertMessageID; - const userMessages = React.useMemo( - () => revertedState.records.map((record) => record.message), - [revertedState], - ); - const noTextContent = t('chat.revertPopover.noTextContent'); - const items = React.useMemo(() => { - if (!revertMessageID) return []; - return revertedState.records.map((record) => ({ - id: record.message.id, - text: getRevertedPreview(record.parts, noTextContent), - })); - }, [noTextContent, revertMessageID, revertedState]); - const firstRevertedMessageId = items[0]?.id; - - React.useEffect(() => { - setCollapsed(true); - }, [revertMessageID, firstRevertedMessageId]); - - const handleRestore = React.useCallback(async (messageId: string) => { - if (!sessionId || restoringId) return; - setRestoringId(messageId); - try { - const nextMessage = userMessages.find((message) => message.id > messageId); - if (nextMessage) { - await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true }); - } else { - await handleSlashRedo(sessionId, { fullUnrevert: true }); - } - } finally { - setRestoringId(null); - } - }, [handleSlashRedo, revertToMessage, restoringId, sessionId, userMessages]); - - const handleFork = React.useCallback(async (messageId: string) => { - if (!sessionId || forkingId) return; - setForkingId(messageId); - try { - await forkFromMessage(sessionId, messageId); - } finally { - setForkingId(null); - } - }, [forkFromMessage, forkingId, sessionId]); - - if (!sessionId || items.length === 0) return null; - - return ( -
-
- - {!collapsed && ( -
- {items.map((item) => ( -
- - {item.text} - - - -
- ))} -
- )} -
-
- ); -}); - -RevertedMessageDock.displayName = 'RevertedMessageDock'; - -type ComposerAttachmentControlsProps = { - isVSCode: boolean; - footerIconButtonClass: string; - iconSizeClass: string; - handlePickLocalFiles: () => void; - openIssuePicker: () => void; - openPrPicker: () => void; - onOpenSettings?: () => void; - onMenuOpenChange?: (open: boolean) => void; - /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */ - onOpenMobileSheet?: () => void; -}; - -const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) { - const { t } = useI18n(); - const { - isVSCode, - footerIconButtonClass, - iconSizeClass, - handlePickLocalFiles, - openIssuePicker, - openPrPicker, - onOpenSettings, - } = props; - - return ( -
-
- {props.onOpenMobileSheet ? ( - - ) : isVSCode ? ( - - ) : ( - - - - - - { - requestAnimationFrame(handlePickLocalFiles); - }} - > - - {t('chat.chatInput.actions.attachFiles')} - - { - requestAnimationFrame(openIssuePicker); - }} - > - - {t('chat.chatInput.actions.linkGithubIssue')} - - { - requestAnimationFrame(openPrPicker); - }} - > - - {t('chat.chatInput.actions.linkGithubPr')} - - - - )} -
- - {onOpenSettings ? ( - - ) : null} -
- ); -}, (prev, next) => ( - prev.isVSCode === next.isVSCode - && prev.footerIconButtonClass === next.footerIconButtonClass - && prev.iconSizeClass === next.iconSizeClass - && prev.onOpenSettings === next.onOpenSettings - && prev.onMenuOpenChange === next.onMenuOpenChange - && prev.onOpenMobileSheet === next.onOpenMobileSheet -)); - -type PermissionAutoAcceptButtonProps = { - footerIconButtonClass: string; - iconSizeClass: string; - isInteractive: boolean; - permissionAutoAcceptEnabled: boolean; - handlePermissionAutoAcceptToggle: () => void; - withTooltip?: boolean; -}; - -const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) { - const { t } = useI18n(); - const { - footerIconButtonClass, - iconSizeClass, - isInteractive, - permissionAutoAcceptEnabled, - handlePermissionAutoAcceptToggle, - withTooltip = false, - } = props; - - const ariaLabel = permissionAutoAcceptEnabled - ? t('chat.chatInput.permissionAutoAccept.disable') - : t('chat.chatInput.permissionAutoAccept.enable'); - const tooltipLabel = permissionAutoAcceptEnabled - ? t('chat.chatInput.permissionAutoAccept.on') - : t('chat.chatInput.permissionAutoAccept.off'); - - const button = ( - - ); - - if (!withTooltip) { - return button; - } - - return ( - - - {button} - - - {tooltipLabel} - - - ); -}); - -type FocusModeButtonProps = { - footerIconButtonClass: string; - iconSizeClass: string; - isExpandedInput: boolean; - onToggle: () => void; -}; - -const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { - const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; - const { t } = useI18n(); - - return ( - - - - - -
- {t('chat.chatInput.focusMode.label')} - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - -
-
-
- ); -}); - -type ComposerActionButtonsProps = { - isMobile: boolean; - footerIconButtonClass: string; - sendIconSizeClass: string; - stopIconSizeClass: string; - canSend: boolean; - canAbort: boolean; - hasContent: boolean; - currentSessionId: string | null; - newSessionDraftOpen: boolean; - onPrimaryAction: () => void; - onQueueMessage: () => void; - onAbort: () => void; -}; - -const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) { - const { - isMobile, - footerIconButtonClass, - sendIconSizeClass, - stopIconSizeClass, - canSend, - canAbort, - hasContent, - currentSessionId, - newSessionDraftOpen, - onPrimaryAction, - onQueueMessage, - onAbort, - } = props; - const { t } = useI18n(); - - const sendButton = ( - - ); - - if (!canAbort) { - return sendButton; - } - - return ( -
- {hasContent ? ( - - ) : null} - -
- ); -}, (prev, next) => ( - prev.isMobile === next.isMobile - && prev.footerIconButtonClass === next.footerIconButtonClass - && prev.sendIconSizeClass === next.sendIconSizeClass - && prev.stopIconSizeClass === next.stopIconSizeClass - && prev.canSend === next.canSend - && prev.canAbort === next.canAbort - && prev.hasContent === next.hasContent - && prev.currentSessionId === next.currentSessionId - && prev.newSessionDraftOpen === next.newSessionDraftOpen - && prev.onPrimaryAction === next.onPrimaryAction - && prev.onQueueMessage === next.onQueueMessage - && prev.onAbort === next.onAbort -)); - -const appendWithLineBreaks = (base: string, next: string): string => { - const separator = !base - ? '' - : base.endsWith('\n\n') - ? '' - : base.endsWith('\n') - ? '\n' - : '\n\n'; - - const nextWithTrailingBreaks = next.endsWith('\n\n') - ? next - : next.endsWith('\n') - ? `${next}\n` - : `${next}\n\n`; - - return `${base}${separator}${nextWithTrailingBreaks}`; -}; - -const appendInlineText = (base: string, next: string): string => { - const nextTrimmed = next.trim(); - if (!nextTrimmed) { - return base; - } - if (!base) { - return `${nextTrimmed} `; - } - const separator = /[\s\n]$/.test(base) ? '' : ' '; - return `${base}${separator}${nextTrimmed} `; -}; - interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: () => void; } -type AutocompleteOverlayPosition = { - top: number; - left: number; - place: 'above' | 'below'; - maxHeight: number; -}; - const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { const sessionState = useSessionUIStore.getState(); const newSessionDirectory = sessionState.newSessionDraft?.open @@ -965,66 +245,31 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return snapshot.text; }); const confirmedMentionsRef = React.useRef>(initialDraftSnapshotRef.current.confirmedMentions); - // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed) - const isConfirmedFilePath = (text: string): boolean => - text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text); const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); const [isDragging, setIsDragging] = React.useState(false); const [isInternalDrag, setIsInternalDrag] = React.useState(false); - const [showFileMention, setShowFileMention] = React.useState(false); - const [mentionQuery, setMentionQuery] = React.useState(''); - const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); - const [commandQuery, setCommandQuery] = React.useState(''); - const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); - const [skillQuery, setSkillQuery] = React.useState(''); - const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false); - const [snippetQuery, setSnippetQuery] = React.useState(''); - const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); + // At most one picker is open at a time; the prompt language decides which. + const [openAutocomplete, setOpenAutocomplete] = React.useState(null); + const [autocompleteQuery, setAutocompleteQuery] = React.useState(''); + const closeAutocomplete = React.useCallback(() => setOpenAutocomplete(null), []); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); - // Mobile pill composer: when the keyboard is closed the composer collapses - // into a narrow pill (+ / placeholder / mic) with a round new-session button - // beside it. Any interaction expands back into the full composer. The swap - // is deliberately INSTANT and synchronized with the keyboard choreography, - // so the chat compensates keyboard + composer height in a single motion. - const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false); - const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false); - // Mobile browser / installed PWA: tapping a composer control while the - // keyboard is up blurs the textarea first, and the keyboard-resize reflow - // moves the control out from under the finger BEFORE the browser - // synthesizes the click — the tap dismisses the keyboard but the control's - // onClick never fires. Defer the blur-driven state flip so the pinned - // composer holds still through the tap; a refocus cancels it. Capacitor - // keeps the immediate flip. - const mobileBlurTimerRef = React.useRef(null); - React.useEffect(() => () => { - if (mobileBlurTimerRef.current !== null) { - window.clearTimeout(mobileBlurTimerRef.current); - } - }, []); - const [mobileDictationActive, setMobileDictationActive] = React.useState(false); const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false); const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null); const [mobileDraftPickerQuery, setMobileDraftPickerQuery] = React.useState(''); - // True while ANY MobileOverlayPanel is open (sessions sheet, model/agent - // panels, pickers...). Opening one closes the keyboard, which must not - // collapse the composer into the pill under the overlay. - const [mobileOverlayHostBusy, setMobileOverlayHostBusy] = React.useState(false); - // Set while an expansion is settling (focus/dictation not yet active) so the - // collapse watcher doesn't immediately fold the composer back into the pill. - const mobileExpandIntentRef = React.useRef<'focus' | null>(null); - // Keyboard restore across overlays: opening an overlay closes the keyboard; - // if it was open at that moment, reopen it when the overlay closes. - const lastMobileBlurAtRef = React.useRef(0); - const restoreKeyboardAfterOverlayRef = React.useRef(false); - // Pill ↔ full composer morph: the wrapper FLIP-animates its height between - // the two shapes while the swapped content fades in. - const composerHandleTouchRef = React.useRef<{ startY: number; fired: boolean } | null>(null); // Message history navigation state (up/down arrow to recall previous messages) - const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent - const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode - const textareaRef = React.useRef(null); + const composerRef = React.useRef(null); + // The mobile composer swaps between the collapsed pill and the full + // composer, which unmounts the editor. Building a CodeMirror view is far + // from free, and it would happen inside the tap that expands the pill — + // before the browser may paint the swap. The store keeps one view alive for + // as long as the composer itself is mounted. + const composerViewStore = React.useRef(createComposerEditorViewStore()).current; + React.useEffect(() => () => { + composerViewStore.view?.destroy(); + composerViewStore.view = null; + }, [composerViewStore]); + const composerFormRef = React.useRef(null); const cursorPosRef = React.useRef(0); - const previousMessageLengthRef = React.useRef(message.length); const dropZoneRef = React.useRef(null); const dragEnterCountRef = React.useRef(0); const suppressNextFileDropTextInsertRef = React.useRef(false); @@ -1039,9 +284,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const snippetRef = React.useRef(null); // Ref to track current message value without triggering re-renders in effects const messageRef = React.useRef(message); - const draftPersistTimerRef = React.useRef | null>(null); - const skipNextDraftPersistRef = React.useRef(false); - const lastPersistedDraftRef = React.useRef>(new Map()); const currentChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); const pendingPastedAttachmentFilenamesRef = React.useRef>(new Set()); @@ -1073,7 +315,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); - const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const attachedFiles = useInputStore((s) => s.attachedFiles); @@ -1091,9 +332,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [currentSessionId], ); const currentManagementSessionId = currentSessionId; - const projects = useProjectsStore((state) => state.projects); - const activeProjectId = useProjectsStore((state) => state.activeProjectId); - const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false); const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false); @@ -1134,7 +372,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const fetchGitStatus = useGitStore((state) => state.fetchStatus); const [showAbortStatus, setShowAbortStatus] = React.useState(false); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); - const composerHighlightRef = React.useRef(null); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ open: false, @@ -1296,28 +533,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return names; }, [availableCommands, availableSkills, isMobile]); - // /command and /skill spans (primary color). Only tokens that match a known - // command/skill name are highlighted — partial/unknown tokens stay plain. - const composerCommandRanges = React.useMemo(() => { - if (!message || !message.includes('/') || inputMode === 'shell' || knownSlashNames.size === 0) { - return []; - } - const ranges: HighlightRange[] = []; - const slashRegex = /(^|\s)\/([A-Za-z0-9][A-Za-z0-9_-]*)/g; - let match: RegExpExecArray | null; - while ((match = slashRegex.exec(message)) !== null) { - const name = match[2]; - if (!knownSlashNames.has(name.toLowerCase())) { - continue; - } - const slashStart = match.index + match[1].length; - ranges.push({ start: slashStart, end: slashStart + 1 + name.length, style: 'mentionCommand' }); - } - return ranges; - }, [inputMode, knownSlashNames, message]); - - // Snippet triggers (#name / #alias). Highlighted like commands once the - // trigger matches a known snippet name or alias. const availableSnippets = useSnippetsStore((s) => s.snippets); const knownSnippetTriggers = React.useMemo(() => { const triggers = new Set(); @@ -1328,85 +543,26 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return triggers; }, [availableSnippets]); - const composerSnippetRanges = React.useMemo(() => { - if (!message || !message.includes('#') || inputMode === 'shell' || knownSnippetTriggers.size === 0) { - return []; - } - const ranges: HighlightRange[] = []; - const snippetRegex = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g; - let match: RegExpExecArray | null; - while ((match = snippetRegex.exec(message)) !== null) { - const trigger = match[2]; - if (!knownSnippetTriggers.has(trigger.toLowerCase())) { - continue; - } - const hashStart = match.index + match[1].length; - ranges.push({ start: hashStart, end: hashStart + 1 + trigger.length, style: 'mentionSnippet' }); - } - return ranges; - }, [inputMode, knownSnippetTriggers, message]); + const attachmentFilenames = React.useMemo( + () => attachedFiles.map((file) => file.filename), + [attachedFiles], + ); - // @mention spans (file = blue, agent = green). Computed as character ranges - // so they can be merged with markdown highlight ranges in a single overlay. - const composerMentionRanges = React.useMemo(() => { - if (!message || !message.includes('@') || inputMode === 'shell') { - return []; - } - const ranges: MentionRange[] = []; - const mentionRegex = /@([^\s]+)/g; - let match: RegExpExecArray | null; - while ((match = mentionRegex.exec(message)) !== null) { - const full = match[0]; - const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, ''); - const start = match.index; - const end = start + full.length; - const charBefore = start > 0 ? message[start - 1] : null; - const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore); - if (!isBoundary || mention.length === 0) { - continue; - } - if (knownAgentNames.has(mention.toLowerCase())) { - ranges.push({ start, end, kind: 'agent' }); - } else if (isConfirmedFilePath(mention)) { - ranges.push({ start, end, kind: 'file' }); - } - } - return ranges; - }, [inputMode, message, knownAgentNames]); - - const attachmentCitationRanges = React.useMemo(() => { - if (!message || !message.includes('[') || inputMode === 'shell' || attachedFiles.length === 0) { - return []; - } - - return findAttachmentCitationRanges( - message, - attachedFiles.map((file) => file.filename), - ).map((range) => ({ - ...range, - style: 'mentionFile' as const, - })); - }, [attachedFiles, inputMode, message]); - - // Combined source-mode highlight: markdown syntax + @mentions. Returns null - // when there's nothing to highlight so the overlay stays off for plain text. - const highlightedComposerContent = React.useMemo(() => { - if (!message || inputMode === 'shell') { - return null; - } - const ranges = [ - ...tokenizeMarkdown(message), - ...highlightFencedCode(message), - ...mentionRangesToHighlightRanges(composerMentionRanges), - ...composerCommandRanges, - ...composerSnippetRanges, - ...attachmentCitationRanges, - ]; - return buildHighlightParts(message, ranges); - }, [attachmentCitationRanges, composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]); + /** + * Everything the prompt language needs to resolve references. Rebuilt only + * when a registry changes, so typing does not churn the tokenizer input. + */ + const languageContext = React.useMemo(() => ({ + inputMode, + knownAgentNames, + confirmedMentions: confirmedMentionsRef.current, + knownSlashNames, + knownSnippetTriggers, + attachmentFilenames, + }), [attachmentFilenames, inputMode, knownAgentNames, knownSlashNames, knownSnippetTriggers]); const sanitizeAttachmentsForSend = React.useCallback( - (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) + (files: readonly AttachedFile[] | undefined): AttachedFile[] => [...(files ?? [])] .map((file) => ({ ...file, dataUrl: file.source === 'server' && file.serverPath @@ -1426,31 +582,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const seenPaths = new Set(); const attachments: AttachedFile[] = []; - const mentionRegex = /@([^\s]+)/g; - let match: RegExpExecArray | null; - while ((match = mentionRegex.exec(rawText)) !== null) { - const rawMentionPath = match[1]; - const offset = match.index; - const original = rawText; - const charBefore = offset > 0 ? original[offset - 1] : null; - if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) { - continue; - } - - const mentionPath = String(rawMentionPath || '') - .trim() - .replace(/^[`"'<(]+/, '') - .replace(/[),.;:!?`"'>]+$/g, ''); - if (!mentionPath) { - continue; - } - - if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) { - continue; - } - - const looksLikeFilePath = isConfirmedFilePath(mentionPath); - if (!looksLikeFilePath) { + for (const token of scanMentions(rawText)) { + const mentionPath = token.name; + const kind = classifyMention(mentionPath, { + knownAgentNames: knownAgentNamesRef.current, + confirmedMentions: confirmedMentionsRef.current, + }); + // Agents are routed separately by parseAgentMentions; only file + // references become attachments here. + if (kind !== 'file') { continue; } @@ -1493,7 +633,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo attachments, }; }, [chatSearchDirectory]); - const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); @@ -1604,7 +743,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. - const userMessageHistory = useUserMessageHistory(currentSessionId ?? ""); + const messageHistory = useMessageHistory(useUserMessageHistory(currentSessionId ?? "")); // Keep messageRef in sync with message state React.useEffect(() => { @@ -1615,96 +754,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo currentChatDraftIdentityRef.current = chatDraftIdentity; }, [chatDraftIdentity]); - const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => { - if (!identity) return; - const key = getChatDraftIdentityKey(identity); - // Only persist confirmed mentions that are actually present in the draft text - const activeMentions = new Set(); - for (const mention of confirmedMentionsRef.current) { - if (draft.includes(`@${mention}`)) { - activeMentions.add(mention); - } - } - confirmedMentionsRef.current = activeMentions; - const signature = getChatDraftSnapshotSignature(draft, activeMentions); - const lastPersisted = lastPersistedDraftRef.current.get(key); - if (lastPersisted === signature) { - return; - } - writeChatDraft(identity, draft, activeMentions); - lastPersistedDraftRef.current.set(key, signature); - }, []); - - const clearPendingDraftPersist = React.useCallback(() => { - if (!draftPersistTimerRef.current) { - return; - } - clearTimeout(draftPersistTimerRef.current); - draftPersistTimerRef.current = null; - }, []); - - // Handle initial draft restoration and text selection - const hasHandledInitialDraftRef = React.useRef(false); - React.useEffect(() => { - if (hasHandledInitialDraftRef.current) return; - hasHandledInitialDraftRef.current = true; - - const draft = initialDraftRef.current; - if (!draft) return; - - if (!persistChatDraft) { - // Setting disabled - clear the restored draft - setMessage(''); - writeChatDraft(initialDraftIdentityRef.current, '', []); - } else { - // Setting enabled - select all text - requestAnimationFrame(() => { - textareaRef.current?.select(); - }); - } - }, [persistChatDraft]); - - // Handle identity switching: save the old draft and restore the new runtime/directory/session draft. - const prevChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); - React.useEffect(() => { - const previousIdentity = prevChatDraftIdentityRef.current; - const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null; - const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null; - if (previousKey !== currentKey) { - prevChatDraftIdentityRef.current = chatDraftIdentity; - setInputMode('normal'); - clearPendingDraftPersist(); - skipNextDraftPersistRef.current = true; - - if (persistChatDraft) { - persistDraftImmediately(previousIdentity, messageRef.current); - const nextSnapshot = readChatDraft(chatDraftIdentity); - setMessage(nextSnapshot.text); - confirmedMentionsRef.current = nextSnapshot.confirmedMentions; - if (nextSnapshot.text) { - requestAnimationFrame(() => { - textareaRef.current?.select(); - }); - } - } else { - // Persist disabled: clear input without saving - setMessage(''); - confirmedMentionsRef.current = new Set(); - } - } - }, [chatDraftIdentity, clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); - - React.useEffect(() => subscribeChatDraftDeletion((deletedIdentity) => { - const deletedKey = getChatDraftIdentityKey(deletedIdentity); - lastPersistedDraftRef.current.set(deletedKey, getChatDraftSnapshotSignature('', [])); - const currentIdentity = currentChatDraftIdentityRef.current; - if (!currentIdentity || getChatDraftIdentityKey(currentIdentity) !== deletedKey) return; - clearPendingDraftPersist(); - skipNextDraftPersistRef.current = true; - messageRef.current = ''; - confirmedMentionsRef.current = new Set(); - setMessage(''); - }), [clearPendingDraftPersist]); + // Draft persistence: identity switching, debounced writes and the + // flush-on-hide edges live in the hook. + const { persistNow: persistDraftImmediately } = useComposerDraft({ + message, + messageRef, + setMessage, + confirmedMentionsRef, + identity: chatDraftIdentity, + persistEnabled: persistChatDraft, + initialDraft: { + text: initialDraftRef.current ?? '', + identity: initialDraftIdentityRef.current, + }, + onIdentityChange: () => setInputMode('normal'), + onDraftRestored: () => composerRef.current?.selectAll(), + }); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); @@ -1714,63 +779,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo requestAnimationFrame(() => { if (isMobile) { // On mobile, use preventScroll to avoid viewport jumping - textareaRef.current?.focus({ preventScroll: true }); + composerRef.current?.focus({ preventScroll: true }); } else { - textareaRef.current?.focus(); + composerRef.current?.focus(); } }); } prevNewSessionDraftOpenRef.current = newSessionDraftOpen; }, [newSessionDraftOpen, isMobile]); - // Persist chat input draft to localStorage per session (only if setting enabled) - React.useEffect(() => { - if (!persistChatDraft) { - clearPendingDraftPersist(); - persistDraftImmediately(chatDraftIdentity, ''); - return; - } - - if (skipNextDraftPersistRef.current) { - skipNextDraftPersistRef.current = false; - return; - } - - clearPendingDraftPersist(); - const draftSnapshot = message; - const identitySnapshot = chatDraftIdentity; - draftPersistTimerRef.current = setTimeout(() => { - draftPersistTimerRef.current = null; - persistDraftImmediately(identitySnapshot, draftSnapshot); - }, CHAT_DRAFT_PERSIST_DEBOUNCE_MS); - - return () => { - clearPendingDraftPersist(); - }; - }, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]); - - React.useEffect(() => { - const flushCurrentDraft = () => { - clearPendingDraftPersist(); - if (persistChatDraft) { - persistDraftImmediately(currentChatDraftIdentityRef.current, messageRef.current); - } - }; - const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') flushCurrentDraft(); - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - document.addEventListener('freeze', flushCurrentDraft); - window.addEventListener('pagehide', flushCurrentDraft); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - document.removeEventListener('freeze', flushCurrentDraft); - window.removeEventListener('pagehide', flushCurrentDraft); - flushCurrentDraft(); - }; - }, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); - // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => { @@ -1788,7 +805,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // keyboard-close lands, otherwise the composer folds into the pill // under the sheet. setMobileControlsPanel(panel); - textareaRef.current?.blur(); + composerRef.current?.blur(); }, [isMobile]); // Consume pending input text (e.g., from revert action) @@ -1809,7 +826,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } // Focus textarea after setting message setTimeout(() => { - textareaRef.current?.focus(); + composerRef.current?.focus(); }, 0); } } @@ -1822,7 +839,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const canAbort = sessionPhase !== 'idle'; const getCurrentInputSnapshot = React.useCallback(() => { - const currentMessage = textareaRef.current?.value ?? message; + const currentMessage = composerRef.current?.getValue() ?? message; return { message: currentMessage, hasContent: currentMessage.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, @@ -1875,14 +892,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (!isMobile) { - textareaRef.current?.focus(); + composerRef.current?.focus(); } }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); setTimeout(() => { - textareaRef.current?.focus(); + composerRef.current?.focus(); }, 0); }, []); @@ -1966,132 +983,48 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const sendMessageOptions = delivery ? { delivery } : undefined; - // Build the primary message (first part) and additional parts - let primaryText = ''; - let primaryAttachments: AttachedFile[] = []; - let agentMentionName: string | undefined; - const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = []; - const availableSkillNames = new Set(useSkillsStore.getState().skills.map((skill) => skill.name)); - const mentionedSkillNames: string[] = []; - const addMentionedSkills = (text: string) => { - for (const name of collectInlineSkillMentions(text, availableSkillNames)) { - if (!mentionedSkillNames.includes(name)) mentionedSkillNames.push(name); - } - }; - - // Consume any pending synthetic parts (from conflict resolution, etc.) + // Inline review comments and synthetic context are consumed before + // assembly so a failed send can restore exactly what it took. const syntheticParts = consumePendingSyntheticParts(); + const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; + const drafts: InlineCommentDraft[] = consumedDraftTarget + ? consumeDrafts(consumedDraftTarget) + : []; - // Process queued messages first - for (let i = 0; i < queuedMessagesToSend.length; i++) { - const queuedMsg = queuedMessagesToSend[i]; - const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); - const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); - addMentionedSkills(queuedText); + const availableSkillNames = new Set( + useSkillsStore.getState().skills.map((skill) => skill.name), + ); - // Use agent mention from first message that has one - if (!agentMentionName && mention?.name) { - agentMentionName = mention.name; - } + const outgoing = buildOutgoingMessage({ + queued: queuedMessagesToSend, + composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null, + composerAttachments: attachedFiles, + inlineComments: drafts, + syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], + linkedIssueContext: linkedIssue?.contextText ?? null, + linkedPr: linkedPr + ? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } + : null, + }, { + parseAgentMention: (text) => { + const { sanitizedText, mention } = parseAgentMentions(text, agents); + return { text: sanitizedText, agentName: mention?.name }; + }, + extractFileMentions: (text) => { + const { sanitizedText, attachments } = extractInlineFileMentions(text); + return { text: sanitizedText, attachments }; + }, + sanitizeAttachments: sanitizeAttachmentsForSend, + collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), + appendComments: (text, comments) => + appendInlineComments(text, comments as InlineCommentDraft[]), + buildSkillInstruction: buildSkillMentionInstruction, + }); - if (i === 0) { - // First queued message becomes primary - primaryText = queuedText; - primaryAttachments = [ - ...sanitizeAttachmentsForSend(queuedMsg.attachments), - ...mentionAttachments, - ]; - } else { - // Subsequent queued messages become additional parts - const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments); - additionalParts.push({ - text: queuedText, - attachments: [...queuedAttachments, ...mentionAttachments], - }); - } - } + let primaryText = outgoing.primaryText; + const { primaryAttachments, additionalParts, agentMentionName } = outgoing; - // Add current input (skip for queued-only auto-send) - if (!queuedOnly && inputSnapshot.hasContent) { - const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); - const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); - const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); - const attachmentsToSend = sanitizeAttachmentsForSend(attachedFiles); - addMentionedSkills(messageText); - - if (!agentMentionName && mention?.name) { - agentMentionName = mention.name; - } - - if (queuedMessagesToSend.length === 0) { - // No queue - current input is primary - primaryText = messageText; - primaryAttachments = [...attachmentsToSend, ...mentionAttachments]; - } else { - // Has queue - current input is additional part - additionalParts.push({ - text: messageText, - attachments: [...attachmentsToSend, ...mentionAttachments], - }); - } - } - - const consumedDraftTarget = inlineDraftTarget; - let drafts: InlineCommentDraft[] = []; - if (!queuedOnly && consumedDraftTarget) { - drafts = consumeDrafts(consumedDraftTarget); - } - - if (drafts.length > 0) { - if (queuedMessagesToSend.length === 0) { - primaryText = appendInlineComments(primaryText, drafts); - } else if (additionalParts.length > 0) { - const lastPart = additionalParts[additionalParts.length - 1]; - lastPart.text = appendInlineComments(lastPart.text, drafts); - } else { - primaryText = appendInlineComments(primaryText, drafts); - } - } - - // Add synthetic parts (from conflict resolution, etc.) - if (syntheticParts && syntheticParts.length > 0) { - for (const part of syntheticParts) { - additionalParts.push({ - text: part.text, - synthetic: true, - }); - } - } - - // Add linked issue as synthetic part (only the parts with synthetic: true) - // The text part (synthetic: false) is completely dropped per requirements - if (linkedIssue) { - additionalParts.push({ - text: linkedIssue.contextText, - synthetic: true, - }); - } - - if (linkedPr) { - additionalParts.push({ - text: linkedPr.instructionsText, - synthetic: true, - }); - additionalParts.push({ - text: linkedPr.contextText, - synthetic: true, - }); - } - - const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames); - if (skillMentionInstruction) { - additionalParts.push({ - text: skillMentionInstruction, - synthetic: true, - }); - } - - if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; + if (outgoing.isEmpty) return; // Clear queue and input if (messageQueueTarget && queuedMessageId) { @@ -2104,9 +1037,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo confirmedMentionsRef.current.clear(); // Clear per-session draft on submit persistDraftImmediately(chatDraftIdentity, ''); - // Reset message history navigation state - setHistoryIndex(-1); - setDraftMessage(''); + messageHistory.reset(); if (attachedFiles.length > 0) { clearAttachedFiles(); } @@ -2115,33 +1046,35 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (isMobile) { - textareaRef.current?.blur(); + composerRef.current?.blur(); } - // Handle local slash commands only in normal mode - const normalizedCommand = primaryText.trimStart(); - if (inputMode === 'normal' && normalizedCommand.startsWith('/')) { - const commandName = normalizedCommand - .slice(1) - .trim() - .split(/\s+/)[0] - ?.toLowerCase(); + // Local slash commands, normal mode only. + const parsedCommand = inputMode === 'normal' ? parseSlashCommand(primaryText) : null; + if (parsedCommand) { + const { name: commandName, argument } = parsedCommand; + // Commands that manipulate session state or open UI rather than + // sending a message. if (commandName === 'undo' && currentSessionId) { await useSessionUIStore.getState().handleSlashUndo(currentSessionId); scrollToBottom?.(); return; } - else if (commandName === 'redo' && currentSessionId) { + if (commandName === 'redo' && currentSessionId) { await useSessionUIStore.getState().handleSlashRedo(currentSessionId); scrollToBottom?.(); return; } - else if (commandName === 'timeline' && currentSessionId) { + if (commandName === 'timeline' && currentSessionId) { setTimelineDialogOpen(true); return; } - else if (commandName === 'compact' && currentSessionId) { + if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) { + setReviewDialogOpen(true); + return; + } + if (commandName === 'compact' && currentSessionId) { try { await sessionActions.waitForConnectionOrThrow(); const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; @@ -2151,18 +1084,20 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } return; } - else if (commandName === 'summary' && currentSessionId) { + + // The rest render a visible prompt plus synthetic instructions and + // send them as one message. + const command = findMagicPromptCommand(commandName); + const commandIsAvailable = command !== null && canRunCommand(command, { + hasSession: Boolean(currentSessionId), + hasDraft: newSessionDraftOpen, + }); + if (command && commandIsAvailable) { + const variables = buildCommandVariables(command, argument); try { await sessionActions.waitForConnectionOrThrow(); - // Everything after `/summary ` is an optional topic hint - // the user wants the summary focused on. - const topic = normalizedCommand.replace(/^\/summary\b/i, '').trim(); - const topicLine = topic ? ` focused on: ${topic}` : ''; - const topicBlock = topic - ? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.` - : ''; - const visibleText = await renderMagicPrompt('session.summary.visible', { topic_line: topicLine }); - const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock }); + const visibleText = await renderMagicPrompt(command.visiblePrompt, variables.visible); + const instructionsText = await renderMagicPrompt(command.instructionsPrompt, variables.instructions); await sendMessage( visibleText, providerIdToSend, @@ -2177,201 +1112,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); scrollToBottom?.(); } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed')); - } - return; - } - else if (commandName === 'workspace-review' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.review.visible'); - const instructionsText = await renderMagicPrompt('session.review.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed')); - } - return; - } - else if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) { - setReviewDialogOpen(true); - return; - } - else if (commandName === 'plan-feature' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.plan.visible'); - const instructionsText = await renderMagicPrompt('session.plan.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.planFeatureFailed')); - } - return; - } - else if (commandName === 'craft-goal' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const idea = normalizedCommand.replace(/^\/craft-goal\b/i, '').trim(); - const visibleText = await renderMagicPrompt('session.craftGoal.visible', { - idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '', - }); - const instructionsText = await renderMagicPrompt('session.craftGoal.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.craftGoalFailed')); - } - return; - } - else if (commandName === 'schedule-task' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const idea = normalizedCommand.replace(/^\/schedule-task\b/i, '').trim(); - const visibleText = await renderMagicPrompt('session.scheduleTask.visible', { - idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '', - }); - const instructionsText = await renderMagicPrompt('session.scheduleTask.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.scheduleTaskFailed')); - } - return; - } - else if (commandName === 'catch-up' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.catchup.visible'); - const instructionsText = await renderMagicPrompt('session.catchup.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.catchUpFailed')); - } - return; - } - else if (commandName === 'debug' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.debug.visible'); - const instructionsText = await renderMagicPrompt('session.debug.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.debugFailed')); - } - return; - } - else if (commandName === 'weigh' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.weigh.visible'); - const instructionsText = await renderMagicPrompt('session.weigh.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.weighFailed')); - } - return; - } - else if (commandName === 'explore' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const visibleText = await renderMagicPrompt('session.explore.visible'); - const instructionsText = await renderMagicPrompt('session.explore.instructions'); - await sendMessage( - visibleText, - providerIdToSend, - modelIdToSend, - agentNameToSend, - [], - agentMentionName, - [{ text: instructionsText, synthetic: true }], - variantToSend, - inputMode, - sendMessageOptions, - ); - scrollToBottom?.(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.exploreFailed')); + toast.error(error instanceof Error ? error.message : t(command.errorToastKey)); } return; } @@ -2453,7 +1194,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo console.error('Message send failed:', rawMessage || error); restoreConsumedDrafts(); - const currentInput = textareaRef.current?.value ?? messageRef.current; + const currentInput = composerRef.current?.getValue() ?? messageRef.current; if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { setMessage(inputSnapshot.message); writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); @@ -2493,7 +1234,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); if (!isMobile) { - textareaRef.current?.focus(); + composerRef.current?.focus(); } }; @@ -2518,25 +1259,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // The text goes straight into the submit (see SubmitOptions.presetText) // instead of through the composer input — the collapsed mobile pill has // no mounted textarea to stage it in. - const draft = (textareaRef.current?.value ?? messageRef.current).trim(); + const draft = (composerRef.current?.getValue() ?? messageRef.current).trim(); const presetText = draft ? `${text}\n${draft}` : text; void handleSubmitRef.current({ presetText }); }, []); // Dictation: insert the transcript inline; optionally submit immediately. - // getCurrentInputSnapshot reads textareaRef.current.value first, so setting + // getCurrentInputSnapshot reads composerRef.current.getValue() first, so setting // it synchronously lets handleSubmit pick up the text in the same tick. const handleDictationInsert = React.useCallback((text: string) => { setMessage((prev) => { - const next = appendInlineText(prev, text); - const textarea = textareaRef.current; - if (textarea) { - textarea.value = next; - } - return next; + // The editor is controlled by this state; getCurrentInputSnapshot + // reads it back, so no imperative write is needed. + return appendInlineText(prev, text); }); setTimeout(() => { - textareaRef.current?.focus(); + composerRef.current?.focus(); }, 0); }, []); @@ -2544,7 +1282,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Same as preset chips: the composed text goes into the submit as an // explicit override instead of being staged in the textarea, which may // not be mounted (collapsed mobile pill). - const next = appendInlineText(textareaRef.current?.value ?? messageRef.current, text); + const next = appendInlineText(composerRef.current?.getValue() ?? messageRef.current, text); void handleSubmitRef.current({ presetText: next }); }, []); @@ -2557,7 +1295,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (text) submitPresetPrompt(text); }, [pendingPresetSubmit, submitPresetPrompt]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete. // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. if (isIMECompositionEvent(e)) return; @@ -2574,52 +1312,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) { - const textarea = textareaRef.current; - const selectionStart = textarea?.selectionStart ?? message.length; - const selectionEnd = textarea?.selectionEnd ?? message.length; - const hasCollapsedSelection = selectionStart === selectionEnd; - - if (hasCollapsedSelection) { - const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; - if (probeIndex >= 0 && probeIndex < message.length) { - let tokenStart = probeIndex; - while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) { - tokenStart -= 1; - } - - let tokenEnd = probeIndex + 1; - while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) { - tokenEnd += 1; - } - - const token = message.slice(tokenStart, tokenEnd); - const mentionContent = token.slice(1); - const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) - && !knownAgentNamesRef.current.has(mentionContent.toLowerCase()) - && isConfirmedFilePath(mentionContent); - - if (looksLikeFileMention) { - confirmedMentionsRef.current.delete(mentionContent); - const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd; - const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`; - e.preventDefault(); - setMessage(nextMessage); - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = tokenStart; - textareaRef.current.selectionEnd = tokenStart; - } - adjustTextareaHeight(); - }); - updateAutocompleteState(nextMessage, tokenStart); - return; - } - } - } - } - - if (showCommandAutocomplete && commandRef.current) { + if (openAutocomplete === 'command' && commandRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2628,7 +1321,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showSkillAutocomplete && skillRef.current) { + if (openAutocomplete === 'skill' && skillRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2637,7 +1330,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showSnippetAutocomplete && snippetRef.current) { + if (openAutocomplete === 'snippet' && snippetRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2646,7 +1339,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showFileMention && mentionRef.current) { + if (openAutocomplete === 'mention' && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2670,7 +1363,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? 1 : 0; - if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { + if (cycleAgentDirection !== 0 && openAutocomplete === null) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); @@ -2680,30 +1373,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Handle ArrowUp/ArrowDown for message history navigation // ArrowUp: only when cursor at start (position 0) or input is empty // ArrowDown: also works when cursor at end (to cycle forward through history) - const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention; - const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0; - const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length; + const isAnyAutocompleteOpen = openAutocomplete !== null; + const cursorAtStart = composerRef.current?.getSelection().start === 0 && composerRef.current?.getSelection().end === 0; + const cursorAtEnd = composerRef.current?.getSelection().start === message.length && composerRef.current?.getSelection().end === message.length; const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart); const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd); // Markdown-aware auto-pairing (source mode), normal input only. if (inputMode === 'normal' && !isAnyAutocompleteOpen && !e.metaKey && !e.ctrlKey && !e.altKey) { - const ta = textareaRef.current; - const selStart = ta?.selectionStart ?? -1; - const selEnd = ta?.selectionEnd ?? -1; + const ta = composerRef.current; + const selStart = ta?.getSelection().start ?? -1; + const selEnd = ta?.getSelection().end ?? -1; if (ta && selStart >= 0) { const applyEdit = (next: string, caretStart: number, caretEnd: number) => { e.preventDefault(); setMessage(next); - requestAnimationFrame(() => { - const current = textareaRef.current; - if (current) { - current.selectionStart = caretStart; - current.selectionEnd = caretEnd; - } - adjustTextareaHeight(); - }); + composerRef.current?.setSelection(caretStart, caretEnd); updateAutocompleteState(next, caretEnd); }; @@ -2736,40 +1422,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) { + if (e.key === 'ArrowUp' && canNavigateHistoryUp) { e.preventDefault(); - if (historyIndex === -1) { - // Entering history mode - save current input as draft - setDraftMessage(message); - setHistoryIndex(0); - setMessage(userMessageHistory[0]); - } else if (historyIndex < userMessageHistory.length - 1) { - // Navigate to older message - const newIndex = historyIndex + 1; - setHistoryIndex(newIndex); - setMessage(userMessageHistory[newIndex]); + const recalled = messageHistory.older(message); + if (recalled !== null) { + setMessage(recalled); + // Caret to the start, so the recalled message reads from its + // beginning rather than from wherever the draft's caret was. + requestAnimationFrame(() => composerRef.current?.setSelection(0, 0)); } - // Move cursor to start after history navigation - requestAnimationFrame(() => { - textareaRef.current?.setSelectionRange(0, 0); - }); - // If at oldest message, do nothing return; } - if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) { + if (e.key === 'ArrowDown' && canNavigateHistoryDown) { e.preventDefault(); - if (historyIndex === 0) { - // Exit history mode - restore draft - setHistoryIndex(-1); - setMessage(draftMessage); - setDraftMessage(''); - } else { - // Navigate to newer message - const newIndex = historyIndex - 1; - setHistoryIndex(newIndex); - setMessage(userMessageHistory[newIndex]); - } + const recalled = messageHistory.newer(); + if (recalled !== null) setMessage(recalled); return; } @@ -2800,128 +1468,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }; - const measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => { - const doc = textarea.ownerDocument; - const win = doc.defaultView; - if (!win) return null; - - const style = win.getComputedStyle(textarea); - const mirror = doc.createElement('div'); - const mirrorStyle = mirror.style; - - mirrorStyle.position = 'absolute'; - mirrorStyle.visibility = 'hidden'; - mirrorStyle.pointerEvents = 'none'; - mirrorStyle.whiteSpace = 'pre-wrap'; - mirrorStyle.wordWrap = 'break-word'; - mirrorStyle.overflow = 'hidden'; - mirrorStyle.left = '-9999px'; - mirrorStyle.top = '0'; - - mirrorStyle.width = `${textarea.clientWidth}px`; - mirrorStyle.font = style.font; - mirrorStyle.fontSize = style.fontSize; - mirrorStyle.fontFamily = style.fontFamily; - mirrorStyle.fontWeight = style.fontWeight; - mirrorStyle.fontStyle = style.fontStyle; - mirrorStyle.fontVariant = style.fontVariant; - mirrorStyle.letterSpacing = style.letterSpacing; - mirrorStyle.textTransform = style.textTransform; - mirrorStyle.textIndent = style.textIndent; - mirrorStyle.padding = style.padding; - mirrorStyle.border = style.border; - mirrorStyle.boxSizing = style.boxSizing; - mirrorStyle.lineHeight = style.lineHeight; - mirrorStyle.tabSize = style.tabSize; - - mirror.textContent = textarea.value.slice(0, cursorPosition); - const marker = doc.createElement('span'); - marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' '; - mirror.appendChild(marker); - - doc.body.appendChild(mirror); - const top = marker.offsetTop; - const left = marker.offsetLeft; - doc.body.removeChild(mirror); - - return { top, left }; - }, []); - - const updateAutocompleteOverlayPosition = React.useCallback(() => { - if (!isDesktopExpanded) { - setAutocompleteOverlayPosition(null); - return; - } - - if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { - setAutocompleteOverlayPosition(null); - return; - } - - const textarea = textareaRef.current; - const container = dropZoneRef.current; - if (!textarea || !container) return; - - const cursor = textarea.selectionStart ?? message.length; - const caret = measureCaretInTextarea(textarea, cursor); - if (!caret) return; - - const textareaRect = textarea.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop); - const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft); - - const popupMargin = 8; - const estimatedPopupHeight = 260; - const spaceAbove = caretY - popupMargin; - const spaceBelow = containerRect.height - caretY - popupMargin; - const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above'; - - const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360; - const clampedLeft = Math.max( - popupMargin, - Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin) - ); - - const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove)); - - setAutocompleteOverlayPosition({ - top: place === 'below' ? caretY + 22 : caretY - 6, - left: clampedLeft, - place, - maxHeight, - }); - }, [ - isDesktopExpanded, - measureCaretInTextarea, - message.length, - showCommandAutocomplete, - showFileMention, - showSnippetAutocomplete, - showSkillAutocomplete, - ]); - - React.useLayoutEffect(() => { - updateAutocompleteOverlayPosition(); - }, [ - updateAutocompleteOverlayPosition, + // Focus mode places the open picker at the caret; elsewhere each picker + // anchors to the composer itself. + const { + position: autocompleteOverlayPosition, + update: updateAutocompleteOverlayPosition, + } = useAutocompletePosition({ + enabled: isDesktopExpanded, + openAutocomplete, message, - showCommandAutocomplete, - showSkillAutocomplete, - showSnippetAutocomplete, - showFileMention, - isDesktopExpanded, - ]); - - React.useEffect(() => { - if (!isDesktopExpanded) return; - const onResize = () => updateAutocompleteOverlayPosition(); - window.addEventListener('resize', onResize); - return () => { - window.removeEventListener('resize', onResize); - }; - }, [isDesktopExpanded, updateAutocompleteOverlayPosition]); + editorRef: composerRef, + containerRef: dropZoneRef, + }); const startAbortIndicator = React.useCallback(() => { if (abortTimeoutRef.current) { @@ -2955,168 +1513,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); - // Height the dictation transcript needs (null when idle): the overlay sits - // absolutely over the composer, so the underlying textarea must grow for - // the composer to grow — feed this into the autosize below. - const dictationContentHeightRef = React.useRef(null); + // Height the dictation transcript needs (null when idle). Its overlay sits + // absolutely over the composer, so the composer must be able to grow for + // it. The editor sizes itself to its own content; this is the one external + // constraint, applied as a floor on the editor's container. const [dictationContentHeight, setDictationContentHeight] = React.useState(null); const handleDictationContentHeightChange = React.useCallback((height: number | null) => { setDictationContentHeight((prev) => (prev === height ? prev : height)); }, []); - const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - - const previousScrollTop = textarea.scrollTop; - - if (isComposerExpanded) { - textarea.style.height = '100%'; - textarea.style.maxHeight = 'none'; - setTextareaSize(null); - if (textarea.scrollTop !== previousScrollTop) { - textarea.scrollTop = previousScrollTop; - } - return; - } - - if (options?.allowShrink ?? true) { - textarea.style.height = 'auto'; - } - - const view = textarea.ownerDocument?.defaultView; - const computedStyle = view ? view.getComputedStyle(textarea) : null; - const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN; - const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN; - const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN; - const fallbackLineHeight = 22; - const fallbackPadding = 16; - const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom) - ? fallbackPadding - : paddingTop + paddingBottom; - const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight; - const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal; - const scrollHeight = textarea.scrollHeight || textarea.offsetHeight; - const dictationHeight = dictationContentHeightRef.current ?? 0; - const nextHeight = Math.min(Math.max(scrollHeight, dictationHeight), maxHeight); - - textarea.style.height = `${nextHeight}px`; - textarea.style.maxHeight = `${maxHeight}px`; - if (textarea.scrollTop !== previousScrollTop) { - textarea.scrollTop = previousScrollTop; - } - - setTextareaSize((prev) => { - if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) { - return prev; - } - return { height: nextHeight, maxHeight }; - }); - }, [isComposerExpanded]); - - React.useLayoutEffect(() => { - const allowShrink = message.length < previousMessageLengthRef.current; - previousMessageLengthRef.current = message.length; - adjustTextareaHeight({ allowShrink }); - }, [adjustTextareaHeight, message, isMobile]); - - React.useLayoutEffect(() => { - dictationContentHeightRef.current = dictationContentHeight; - // Growing transcript never shrinks mid-recording (matches typing); - // dictation ending (null) releases the height back to the message. - adjustTextareaHeight({ allowShrink: dictationContentHeight === null }); - }, [adjustTextareaHeight, dictationContentHeight]); - const updateAutocompleteState = React.useCallback(( value: string, cursorPosition: number, inputSource: FileMentionAutocompleteInputSource = 'manual', insertedText?: string, ) => { - if (inputMode === 'shell') { - setShowCommandAutocomplete(false); - setShowFileMention(false); - setShowSkillAutocomplete(false); - setShowSnippetAutocomplete(false); - return; - } - - if (value.startsWith('/')) { - const firstSpace = value.indexOf(' '); - const firstNewline = value.indexOf('\n'); - const commandEnd = Math.min( - firstSpace === -1 ? value.length : firstSpace, - firstNewline === -1 ? value.length : firstNewline - ); - - if (cursorPosition <= commandEnd && firstSpace === -1) { - const commandText = value.substring(1, commandEnd); - setCommandQuery(commandText); - setShowCommandAutocomplete(true); - setShowFileMention(false); - setShowSkillAutocomplete(false); - setShowSnippetAutocomplete(false); - return; - } - } - - setShowCommandAutocomplete(false); - - const textBeforeCursor = value.substring(0, cursorPosition); - - const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); - if (lastSlashSymbol !== -1) { - const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null; - const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1); - const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n'); - const isWordBoundary = !charBefore || /\s/.test(charBefore); - - if (isWordBoundary && !hasSeparator) { - setSkillQuery(textAfterSlash); - setShowSkillAutocomplete(true); - setShowFileMention(false); - return; - } - } - - setShowSkillAutocomplete(false); - setSkillQuery(''); - - const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); - if (lastHashSymbol !== -1) { - const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; - const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); - const isWordBoundary = !charBefore || /\s/.test(charBefore); - if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) { - setSnippetQuery(textAfterHash); - setShowSnippetAutocomplete(true); - setShowFileMention(false); - return; - } - } - - setShowSnippetAutocomplete(false); - - const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText }); - if (nextMentionQuery === null) { - setShowFileMention(false); - } else { - setMentionQuery(nextMentionQuery); - setShowFileMention(true); - } - }, [ - inputMode, - setCommandQuery, - setMentionQuery, - setShowCommandAutocomplete, - setShowFileMention, - setShowSkillAutocomplete, - setShowSnippetAutocomplete, - setSkillQuery, - setSnippetQuery, - ]); + const trigger = resolveAutocompleteTrigger(value, cursorPosition, { + inputMode, + inputSource, + insertedText, + }); + setOpenAutocomplete(trigger?.kind ?? null); + setAutocompleteQuery(trigger?.query ?? ''); + }, [inputMode]); const insertTextAtSelection = React.useCallback(( text: string, @@ -3126,32 +1545,25 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - const textarea = textareaRef.current; - if (!textarea) { + const editor = composerRef.current; + if (!editor) { + // No mounted editor (collapsed mobile pill): append to the state + // the editor will be seeded from. const nextValue = message + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length, inputSource, text); - requestAnimationFrame(() => adjustTextareaHeight()); return; } - const start = textarea.selectionStart ?? message.length; - const end = textarea.selectionEnd ?? message.length; + const { start, end } = editor.getSelection(); const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; - setMessage(nextValue); const cursorPosition = start + text.length; - requestAnimationFrame(() => { - const currentTextarea = textareaRef.current; - if (currentTextarea) { - currentTextarea.selectionStart = cursorPosition; - currentTextarea.selectionEnd = cursorPosition; - } - adjustTextareaHeight(); - }); - + // One dispatch places both the text and the caret, so there is no + // frame where the caret sits at a stale offset. + editor.insertText(text); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); - }, [adjustTextareaHeight, message, updateAutocompleteState]); + }, [message, updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; @@ -3190,65 +1602,37 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, 700); }, []); - const handleBeforeInput = React.useCallback((e: React.FormEvent) => { - if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) { - return; - } - - const nativeInputEvent = e.nativeEvent as InputEvent | undefined; - if (nativeInputEvent?.inputType === 'insertFromDrop') { - e.preventDefault(); - clearDropTextSuppression(); - } - }, [clearDropTextSuppression]); - - const handleTextChange = (e: React.ChangeEvent) => { - const nativeInputEvent = e.nativeEvent as InputEvent | undefined; + const handleComposerChange = ({ value, selection, fromPaste, insertedText }: ComposerChange) => { + // VS Code drops the dragged path as text as well as firing the drop + // handler; swallow that duplicate insertion. if (isVSCodeRuntime() && suppressNextFileDropTextInsertRef.current) { const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef.current; - const isLikelyDropTextInsertion = nativeInputEvent?.inputType === 'insertFromDrop' - || candidateAbsolutePaths.some((path) => path.length > 0 && e.target.value.includes(path)); - - if (isLikelyDropTextInsertion) { + if (candidateAbsolutePaths.some((path) => path.length > 0 && value.includes(path))) { clearDropTextSuppression(); return; } } - const value = e.target.value; - const cursorPosition = e.target.selectionStart ?? value.length; - const pastedInsertedText = nativeInputEvent?.inputType?.startsWith('insertFromPaste') - ? getInsertedTextFromChange(messageRef.current, value) - : ''; + const pastedInsertedText = fromPaste ? insertedText : ''; const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current; if (suppressNextFileMentionPasteRef.current) { clearFileMentionPasteSuppression(); } - const inputSource: FileMentionAutocompleteInputSource = isPasteInput - ? 'paste' - : 'manual'; + const inputSource: FileMentionAutocompleteInputSource = isPasteInput ? 'paste' : 'manual'; + // A leading `!` switches the composer into shell mode and is consumed. if (inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); - const nextCursor = Math.max(0, cursorPosition - 1); + const nextCursor = Math.max(0, selection.start - 1); setInputMode('shell'); setMessage(shellCommand); - adjustTextareaHeight(); - setShowCommandAutocomplete(false); - setShowSkillAutocomplete(false); - setShowFileMention(false); - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; - } - }); + closeAutocomplete(); + requestAnimationFrame(() => composerRef.current?.setSelection(nextCursor)); return; } setMessage(value); - adjustTextareaHeight(); - updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText); + updateAutocompleteState(value, selection.start, inputSource, pastedInsertedText); }; React.useEffect(() => { @@ -3258,35 +1642,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); - const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => { + const handlePaste = React.useCallback(async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + // Narrowed alias so the rest of the handler reads as it did when this + // was a React synthetic event, whose clipboardData is never null. + const e = { ...event, clipboardData, preventDefault: () => event.preventDefault() }; + // Pasting a URL over a selection wraps it as a markdown link: // [selected text](pasted url). if (inputMode === 'normal' && (currentSessionId || newSessionDraftOpen)) { - const ta = textareaRef.current; - const selStart = ta?.selectionStart ?? -1; - const selEnd = ta?.selectionEnd ?? -1; + const ta = composerRef.current; + const selStart = ta?.getSelection().start ?? -1; + const selEnd = ta?.getSelection().end ?? -1; if (ta && selEnd > selStart) { const clipboardText = e.clipboardData.getData('text'); const url = clipboardText.trim(); const selected = message.slice(selStart, selEnd); - if ( - PASTE_LINK_URL_PATTERN.test(url) - && !/\s/.test(url) - && selected.trim().length > 0 - && !selected.includes('](') - ) { + if (shouldWrapSelectionAsLink(url, selected)) { e.preventDefault(); const next = `${message.slice(0, selStart)}[${selected}](${url})${message.slice(selEnd)}`; const caret = selStart + 1 + selected.length + 2 + url.length + 1; setMessage(next); - requestAnimationFrame(() => { - const current = textareaRef.current; - if (current) { - current.selectionStart = caret; - current.selectionEnd = caret; - } - adjustTextareaHeight(); - }); + composerRef.current?.setSelection(caret, caret); updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url); return; } @@ -3336,9 +1714,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ], ); const citationText = buildAttachmentCitationText(assignedFilenames); - const textarea = textareaRef.current; - const selectionStart = textarea?.selectionStart ?? message.length; - const selectionEnd = textarea?.selectionEnd ?? message.length; + const textarea = composerRef.current; + const selectionStart = textarea?.getSelection().start ?? message.length; + const selectionEnd = textarea?.getSelection().end ?? message.length; const insertionText = withInlineInsertionBoundaries( buildImagePasteInsertion(pastedText, citationText), message.slice(0, selectionStart), @@ -3360,17 +1738,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { - const cursorPosition = textareaRef.current?.selectionStart || 0; + const cursorPosition = composerRef.current?.getSelection().start || 0; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) ? file.relativePath.trim() - : (toProjectRelativeMentionPath(file.path) || file.name); + : (toMentionPath(file.path) || file.name); confirmedMentionsRef.current.add(mentionPath); @@ -3382,14 +1760,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = lastAtSymbol + mentionPath.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - } else if (textareaRef.current) { + } else if (composerRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${mentionPath} ` + @@ -3397,24 +1773,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = cursorPosition + mentionPath.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowFileMention(false); - setMentionQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleAgentSelect = (agentName: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); @@ -3427,14 +1800,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = lastAtSymbol + agentName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - } else if (textareaRef.current) { + } else if (composerRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${agentName} ` + @@ -3443,24 +1814,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = cursorPosition + agentName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowFileMention(false); - setMentionQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleSkillSelect = (skillName: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); @@ -3473,24 +1841,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = lastSlashSymbol + skillName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowSkillAutocomplete(false); - setSkillQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleSnippetSelect = (_snippet: unknown, trigger: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; @@ -3498,38 +1863,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = startIndex + trigger.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - setShowSnippetAutocomplete(false); - setSnippetQuery(''); - textareaRef.current?.focus(); + closeAutocomplete(); + composerRef.current?.focus(); }; const handleCommandSelect = (command: CommandInfo) => { setMessage(`/${command.name} `); - const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command }; - if (textareaElement) { - textareaElement._commandMetadata = command; - } - - setShowCommandAutocomplete(false); - setCommandQuery(''); + closeAutocomplete(); const refocus = () => { - if (textareaRef.current) { + if (composerRef.current) { try { - textareaRef.current.focus({ preventScroll: true }); + composerRef.current.focus({ preventScroll: true }); } catch { - textareaRef.current.focus(); + composerRef.current.focus(); } - textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); + composerRef.current.setSelection(composerRef.current.getValue().length, composerRef.current.getValue().length); } }; @@ -3542,8 +1898,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { - if (currentSessionId && textareaRef.current && !isMobile) { - textareaRef.current.focus(); + if (currentSessionId && composerRef.current && !isMobile) { + composerRef.current.focus(); } }, [currentSessionId, isMobile]); @@ -3563,118 +1919,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen); }, [currentSessionId, newSessionDraftOpen]); - const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => { - if (!dataTransfer) return false; - if (dataTransfer.files && dataTransfer.files.length > 0) return true; - if (dataTransfer.types) { - const types = Array.from(dataTransfer.types); - const lowerTypes = types.map((type) => type.toLowerCase()); - if (lowerTypes.includes('files')) return true; - if (lowerTypes.includes('text/uri-list')) return true; - if (lowerTypes.includes('codefiles')) return true; - if (lowerTypes.includes('application/x-openchamber-file-path')) return true; - if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true; - } - - for (const dataType of VS_CODE_DROP_DATA_TYPES) { - let payload = ''; - try { - payload = dataTransfer.getData(dataType); - } catch { - continue; - } - if (payload && parseDroppedFileReferences(payload).length > 0) { - return true; - } - } - - return false; - }, []); - - const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => { - if (!dataTransfer) return []; - - const directFiles = Array.from(dataTransfer.files || []); - if (directFiles.length > 0) { - return directFiles; - } - - const fromItems = Array.from(dataTransfer.items || []) - .filter((item) => item.kind === 'file') - .map((item) => item.getAsFile()) - .filter((file): file is File => Boolean(file)); - - return fromItems; - }, []); - - const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => { - if (!dataTransfer || typeof dataTransfer.getData !== 'function') return []; - - const extracted = new Set(); - - for (const dataType of VS_CODE_DROP_DATA_TYPES) { - let rawPayload = ''; - try { - rawPayload = dataTransfer.getData(dataType); - } catch { - continue; - } - if (!rawPayload) { - continue; - } - - for (const candidate of parseDroppedFileReferences(rawPayload)) { - extracted.add(candidate); - } - } - - return Array.from(extracted); - }, []); - - const normalizeDroppedPath = React.useCallback((rawPath: string): string => { - const input = rawPath.trim(); - if (!input.toLowerCase().startsWith('file://')) { - return input; - } - - try { - let pathname = decodeURIComponent(new URL(input).pathname || ''); - if (/^\/[A-Za-z]:\//.test(pathname)) { - pathname = pathname.slice(1); - } - return pathname || input; - } catch { - const stripped = input.replace(/^file:\/\//i, ''); - try { - return decodeURIComponent(stripped); - } catch { - return stripped; - } - } - }, []); - - const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => { - const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim(); - const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, ''); - if (!normalizedRoot) { - return normalizedAbsolutePath; - } - if (normalizedAbsolutePath === normalizedRoot) { - return normalizedAbsolutePath; - } - const rootWithSlash = `${normalizedRoot}/`; - if (normalizedAbsolutePath.startsWith(rootWithSlash)) { - return normalizedAbsolutePath.slice(rootWithSlash.length); - } - return normalizedAbsolutePath; - }, [chatSearchDirectory]); + // Mention paths are shown relative to the project the chat searches. + const toMentionPath = React.useCallback( + (absolutePath: string) => toProjectRelativeMentionPath(absolutePath, chatSearchDirectory || ""), + [chatSearchDirectory], + ); const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => { if (uris.length === 0) return; const paths = uris .map((entry) => normalizeDroppedPath(entry)) - .map((entry) => toProjectRelativeMentionPath(entry)) + .map((entry) => toMentionPath(entry)) .map((entry) => entry.trim().replace(/^\.\//, '')) .filter((entry) => entry.length > 0); @@ -3690,7 +1946,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setPendingInputText(mentions.join(' '), 'append-inline'); toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length })); - }, [normalizeDroppedPath, setPendingInputText, t, toProjectRelativeMentionPath]); + }, [setPendingInputText, t, toMentionPath]); const handleDragEnter = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { @@ -3757,25 +2013,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (internalPath && internalPath !== '.') { confirmedMentionsRef.current.add(internalPath); const mention = `@${internalPath}`; - const textarea = textareaRef.current; + const textarea = composerRef.current; const currentMessage = messageRef.current; if (textarea) { - const pos = textarea.selectionStart ?? cursorPosRef.current; - const end = textarea.selectionEnd ?? pos; + const { start: pos, end } = textarea.getSelection(); const before = currentMessage.slice(0, pos); const after = currentMessage.slice(end); const needSpaceBefore = before.length > 0 && !/\s$/.test(before); const needSpaceAfter = after.length > 0 && !/^\s/.test(after); const insert = `${needSpaceBefore ? ' ' : ''}${mention}${needSpaceAfter ? ' ' : ''}`; - const nextMessage = `${before}${insert}${after}`; - setMessage(nextMessage); - requestAnimationFrame(() => { - const cursorPos = pos + insert.length; - textarea.selectionStart = cursorPos; - textarea.selectionEnd = cursorPos; - cursorPosRef.current = cursorPos; - textarea.focus(); - }); + // Insert through the editor rather than setMessage: an editor + // dispatch places the caret right after the mention, while the + // external-rewrite path would send it to the end of the + // message and pin the scroll to the bottom. + textarea.replaceRange(pos, end, insert); + cursorPosRef.current = pos + insert.length; + textarea.focus(); } else { setMessage((prev) => appendInlineText(prev, mention)); } @@ -3908,169 +2161,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const isVSCode = isVSCodeRuntime(); const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode; - const selectedDraftProject = React.useMemo(() => { - const explicit = newSessionDraft?.selectedProjectId - ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null - : null; - if (explicit) { - return explicit; - } - - const active = activeProjectId - ? projects.find((project) => project.id === activeProjectId) ?? null - : null; - if (active) { - return active; - } - - return projects[0] ?? null; - }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); - - const selectedDraftProjectPath = React.useMemo( - () => normalizePath(selectedDraftProject?.path ?? null), - [selectedDraftProject?.path], - ); - const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; - - const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); - const selectedDraftProjectBranchesFetchedAt = useGitStore( - (s) => (selectedDraftProjectPath ? s.directories.get(selectedDraftProjectPath)?.lastBranchesFetch ?? 0 : 0), - ); - const selectedDraftProjectIsGitRepo = useIsGitRepo(selectedDraftProjectPath); - const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all); - const fetchBranches = useGitStore((state) => state.fetchBranches); - const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); - - React.useEffect(() => { - if (!showDraftTargetSelectors || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) { - return; - } - - void fetchGitStatus(selectedDraftProjectPath, runtimeGit, { silent: true }); - }, [fetchGitStatus, runtimeGit, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); - - React.useEffect(() => { - if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit || selectedDraftProjectIsGitRepo !== true) { - setIsDiscoveringDraftBranches(false); - return; - } - - // Stale-while-revalidate: branches seeded from the persisted cache show - // instantly. Refresh based on staleness (not mere presence) so a cached - // list can't go stale, while only showing the discovering spinner when - // there is nothing to display yet. - const DRAFT_BRANCHES_SWR_TTL_MS = 30_000; - const isStale = - !selectedDraftProjectBranchesFetchedAt || - Date.now() - selectedDraftProjectBranchesFetchedAt > DRAFT_BRANCHES_SWR_TTL_MS; - - if (hasDraftBranchList && !isStale) { - setIsDiscoveringDraftBranches(false); - return; - } - - let cancelled = false; - setIsDiscoveringDraftBranches(!hasDraftBranchList); - - void fetchBranches(selectedDraftProjectPath, runtimeGit) - .finally(() => { - if (!cancelled) { - setIsDiscoveringDraftBranches(false); - } - }); - - return () => { - cancelled = true; - }; - }, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranchesFetchedAt, hasDraftBranchList, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); - - const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? ''; - - const projectRootBranchOption = React.useMemo(() => { - if (!selectedDraftProject) { - return null; - } - const value = normalizePath(selectedDraftProject.path); - if (!value) { - return null; - } - if (!selectedDraftProjectCurrentBranch) { - return null; - } - return { - value, - label: selectedDraftProjectCurrentBranch, - }; - }, [selectedDraftProject, selectedDraftProjectCurrentBranch]); - - const worktreeBranchOptions = React.useMemo(() => { - if (!selectedDraftProject) { - return []; - } - - const worktrees = (() => { - if (!selectedDraftProjectPath) { - return []; - } - return availableWorktreesByProject.get(selectedDraftProjectPath) - ?? availableWorktreesByProject.get(selectedDraftProject.path) - ?? []; - })(); - - return buildSessionTargetOptions({ - projectRoot: normalizePath(selectedDraftProject.path) ?? '', - rootBranch: selectedDraftProjectCurrentBranch, - worktrees, - pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null, - }).filter((option) => option.kind === 'worktree'); - }, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]); - - const selectedDraftDirectory = React.useMemo( - () => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null) - ?? normalizePath(newSessionDraft?.directoryOverride ?? null) - ?? selectedDraftProjectPath, - [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], - ); - - const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { - const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); - return Boolean( - newSessionDraft?.preserveDirectoryOverride - || - newSessionDraft?.pendingWorktreeRequestId - || (pendingDirectory && pendingDirectory === selectedDraftDirectory) - ); - }, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory]); - - const draftBranchItems = React.useMemo(() => { - const baseItems: Array<{ value: string; label: string }> = []; - if (projectRootBranchOption) { - baseItems.push(projectRootBranchOption); - } - baseItems.push(...worktreeBranchOptions); - - if (!selectedDraftDirectory) { - return baseItems; - } - if (baseItems.some((option) => option.value === selectedDraftDirectory)) { - return baseItems; - } - if (!shouldKeepMissingSelectedDraftDirectory) { - return baseItems; - } - return [ - ...baseItems, - { value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) }, - ]; - }, [projectRootBranchOption, selectedDraftDirectory, shouldKeepMissingSelectedDraftDirectory, worktreeBranchOptions]); - - const selectedDraftBranchLabel = React.useMemo(() => { - const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null; - if (!selectedValue) { - return null; - } - return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue); - }, [draftBranchItems, selectedDraftDirectory]); + // Which project and directory a new session will target. + const { + projects: draftProjects, + selectedDraftProject, + draftProjectLabel, + selectedDraftDirectory, + selectedDraftBranchLabel, + selectedDraftBranchIsKnown, + projectRootBranchOption, + worktreeBranchOptions, + draftBranchItems, + shouldShowDraftBranchSelector, + handleDraftProjectChange, + handleDraftDirectoryChange, + } = useDraftTarget(showDraftTargetSelectors); const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; @@ -4085,110 +2190,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return extractGitChangedFiles(currentGitStatus.files, currentGitStatus.diffStats, currentDirectory).length > 0; }, [currentDirectory, currentGitStatus, isGitRepo, isMiniChatSurface]); - const selectedDraftBranchIsKnown = React.useMemo(() => { - if (!selectedDraftDirectory) { - return true; - } - if (projectRootBranchOption?.value === selectedDraftDirectory) { - return true; - } - return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory); - }, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]); - - React.useEffect(() => { - if (!newSessionDraft?.open || !newSessionDraft?.preserveDirectoryOverride) { - return; - } - if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) { - return; - } - useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false); - }, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]); - - const shouldShowDraftBranchSelector = React.useMemo(() => { - if (selectedDraftProjectIsGitRepo !== true) { - return false; - } - if (isDiscoveringDraftBranches) { - return false; - } - if (projectRootBranchOption) { - return true; - } - return worktreeBranchOptions.length > 0; - }, [isDiscoveringDraftBranches, projectRootBranchOption, selectedDraftProjectIsGitRepo, worktreeBranchOptions.length]); - - const handleDraftProjectChange = React.useCallback((projectId: string) => { - const draft = useSessionUIStore.getState().newSessionDraft; - if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { - return; - } - const project = projects.find((entry) => entry.id === projectId); - if (!project) { - return; - } - if (activeProjectId !== projectId) { - setActiveProjectIdOnly(projectId); - } - setNewSessionDraftTarget({ - projectId, - directoryOverride: project.path, - }, { force: true }); - }, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]); - - const handleDraftDirectoryChange = React.useCallback((directory: string) => { - const draft = useSessionUIStore.getState().newSessionDraft; - if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { - return; - } - if (!selectedDraftProject) { - return; - } - setNewSessionDraftTarget({ - projectId: selectedDraftProject.id, - directoryOverride: directory, - }, { force: true }); - }, [selectedDraftProject, setNewSessionDraftTarget]); - - const renderProjectLabelWithIcon = React.useCallback((project: { - id: string; - path: string; - label?: string; - icon?: string | null; - color?: string | null; - iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; - iconBackground?: string | null; - }) => { - const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const iconColor = getProjectIconColor(project.color); - const fallbackIcon = projectIconName ? ( - - ) : ( - - ); - - return ( - - {project.iconImage ? ( - - - - ) : fallbackIcon} - {getProjectDisplayLabel(project)} - - ); - }, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]); React.useEffect(() => { if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { @@ -4207,31 +2208,35 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); }, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]); - // ── Mobile pill composer state machine ───────────────────────────────── - const expandMobileComposer = React.useCallback((intent: 'focus') => { - mobileExpandIntentRef.current = intent; - // flushSync so the textarea exists NOW and focus() still runs inside - // the user gesture's call stack: mobile browsers only open the soft - // keyboard for focus calls made synchronously from the tap (an rAF - // here worked in the Capacitor WebView but not in Safari/Chrome). - flushSync(() => { - setMobileComposerExpanded(true); - }); - // Capacitor: our keyboard choreography positions everything, so the - // browser's own scroll-into-view must stay off. Mobile BROWSERS have no - // choreography — the native reveal (viewport pan that lifts the focused - // field above the keyboard) is the only thing that moves the composer. - textareaRef.current?.focus({ preventScroll: isCapacitorApp() }); - }, []); + + // Mobile pill composer: the collapse/expand state machine and the + // platform corrections that keep it from fighting the soft keyboard. + const mobileShell = useMobileComposerShell({ + isMobile, + editorRef: composerRef, + formRef: composerFormRef, + setExpandedInput, + holders: { + controlsPanelOpen: Boolean(mobileControlsPanel), + attachMenuOpen: mobileAttachMenuOpen, + draftPickerOpen: mobileDraftPicker !== null, + issuePickerOpen, + prPickerOpen, + isDragging, + }, + }); + const mobileComposerExpanded = mobileShell.expanded; + const mobileTextareaFocused = mobileShell.focused; + const applyAssistSuggestion = React.useCallback((text: string) => { setMessage(text); if (isMobile && !mobileComposerExpanded) { - expandMobileComposer('focus'); + mobileShell.expand(); } else { - requestAnimationFrame(() => textareaRef.current?.focus()); + requestAnimationFrame(() => composerRef.current?.focus()); } - }, [expandMobileComposer, isMobile, mobileComposerExpanded]); + }, [isMobile, mobileComposerExpanded, mobileShell]); const handleMobileNewSession = React.useCallback(() => { @@ -4239,420 +2244,36 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined); }, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]); + /** The dictation engine listens for this globally; the composer only asks. */ + const toggleDictation = React.useCallback(() => { + window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); + }, []); + const openMobileAttachSheet = React.useCallback(() => { // Same order as handleOpenMobilePanel: mark the sheet open BEFORE the // blur so the collapse watcher sees an overlay when the keyboard-close // lands. The trigger button blocks the tap's own focus transfer, so // the keyboard must be dismissed explicitly here. setMobileAttachMenuOpen(true); - textareaRef.current?.blur(); + composerRef.current?.blur(); }, []); - const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded); - React.useEffect(() => { - mobileComposerExpandedRef.current = mobileComposerExpanded; - }); - - const handleMobileDictationActiveChange = React.useCallback((active: boolean) => { - setMobileDictationActive(active); - if (active) { - mobileExpandIntentRef.current = null; - // Dictation engine went live (possibly started from the pill): - // switch straight into the voice variant of the full composer. - if (!mobileComposerExpandedRef.current) { - setMobileComposerExpanded(true); - } - return; - } - // Dictation ended. The insert flow hands focus back to the textarea a - // tick later — if that happened, stay expanded; otherwise (cancel, - // discard, insert-and-send) collapse straight back to the pill without - // parking on the normal composer for the usual grace period. - window.setTimeout(() => { - if (!mobileComposerExpandedRef.current) return; - if (document.activeElement === textareaRef.current) return; - setMobileComposerExpanded(false); - setExpandedInput(false); - }, 30); - }, [setExpandedInput]); - - // Watch the shared overlay portal root: any mounted MobileOverlayPanel - // (sessions sheet, model/agent panels, draft pickers, ...) counts as busy. - // Observing the host catches overlays whose open-state lives in other - // components without threading their state here. - React.useEffect(() => { - if (!isMobile || typeof document === 'undefined') return; - let host = document.getElementById('mobile-overlay-root'); - if (!host) { - // Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot. - host = document.createElement('div'); - host.id = 'mobile-overlay-root'; - document.body.appendChild(host); - } - const hostEl = host; - const update = () => setMobileOverlayHostBusy(hostEl.childElementCount > 0); - update(); - const observer = new MutationObserver(update); - observer.observe(hostEl, { childList: true }); - return () => observer.disconnect(); - }, [isMobile]); - - // If the keyboard was open (or closed just moments ago by the overlay's own - // blur) when an overlay appeared, bring it back once every overlay is gone. - // The attach dropdown and the GitHub issue/PR pickers join the same chain, - // so menu → picker → close restores the keyboard at the end of the flow. - const mobileOverlayOpen = mobileOverlayHostBusy - || Boolean(mobileControlsPanel) - || mobileAttachMenuOpen - || issuePickerOpen - || prPickerOpen; - // Installed PWA (standalone): a focus() from a bare timeout is outside the - // user gesture and iOS refuses to raise the keyboard for it (Safari - // in-browser is lenient). MobileOverlayPanel dispatches - // 'oc:mobile-overlay-closed' synchronously from the same React flush as - // the click that closed it — refocus right there, while the gesture is - // still live. Chained flows (attach menu → GitHub picker) set the skip ref - // so the keyboard doesn't flash open under the next overlay. - const mobilePickerDialogsOpenRef = React.useRef(false); - mobilePickerDialogsOpenRef.current = issuePickerOpen || prPickerOpen; - const skipNextOverlayCloseRestoreRef = React.useRef(false); - const openSheetCountRef = React.useRef(0); - const holdComposerFocusUntilRef = React.useRef(0); - React.useEffect(() => { - if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return; - if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return; - const handleOverlayOpened = () => { - openSheetCountRef.current += 1; - }; - const handleOverlayClosed = () => { - // Counter instead of a DOM check: the close event fires from a - // layout-effect cleanup, when the closing sheet's portal nodes may - // still be attached — the DOM can't tell "this sheet going away" - // from "another sheet still up". - openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1); - if (skipNextOverlayCloseRestoreRef.current) { - skipNextOverlayCloseRestoreRef.current = false; - return; - } - if (!restoreKeyboardAfterOverlayRef.current) return; - if (mobilePickerDialogsOpenRef.current) return; - if (openSheetCountRef.current > 0) return; - restoreKeyboardAfterOverlayRef.current = false; - // iOS can still dismiss the freshly-raised keyboard when the tap - // that closed the overlay finishes over non-input content — hold - // focus through that window (see the onBlur guard). - holdComposerFocusUntilRef.current = Date.now() + 600; - textareaRef.current?.focus(); - // The native focus lands mid-commit; React's delegated onFocus may - // not make it into this flush, leaving mobileComposerBusy false for - // a beat — enough for the pill-collapse timer to unmount the - // focused textarea and kill the rising keyboard. Set the state - // explicitly instead of relying on the synthetic event. - if (document.activeElement === textareaRef.current) { - setMobileTextareaFocused(true); - } - // iOS reveals a field above the keyboard only for user-initiated - // focus; a programmatic one leaves the composer parked behind it - // (the chat screen has no viewport pin of its own — the draft - // screen's pinned form ignores these no-op scrolls). Reveal once - // the keyboard has mostly risen, and again after it settles. - const reveal = () => { - const ta = textareaRef.current; - if (!ta || document.activeElement !== ta) return; - // Align the BOTTOM of the whole composer form with the visible - // bottom: 'nearest' on the textarea alone leaves the footer - // icon row parked behind the keyboard accessory bar. - (composerFormRef.current ?? ta).scrollIntoView({ block: 'end' }); - }; - window.setTimeout(reveal, 300); - window.setTimeout(reveal, 650); - }; - window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened); - window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed); - return () => { - window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened); - window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed); - }; - }, [isMobile]); - React.useEffect(() => { - if (!isMobile) return; - if (mobileOverlayOpen) { - if (mobileTextareaFocused || Date.now() - lastMobileBlurAtRef.current < 800) { - restoreKeyboardAfterOverlayRef.current = true; - } - return; - } - if (!restoreKeyboardAfterOverlayRef.current) return; - // Debounced: overlay chains hand off with a frame of "nothing open" - // between steps (attach sheet closes → issue/PR picker opens a frame - // later). Restoring instantly in that gap would pop the keyboard open - // inside the next overlay — wait out the gap and cancel if another - // overlay appears. - const timer = window.setTimeout(() => { - restoreKeyboardAfterOverlayRef.current = false; - // Browsers need their native scroll-into-view (see expandMobileComposer). - textareaRef.current?.focus({ preventScroll: isCapacitorApp() }); - }, 180); - return () => window.clearTimeout(timer); - }, [isMobile, mobileOverlayOpen, mobileTextareaFocused]); - - // Fold the full composer back into the pill once nothing keeps it open: - // keyboard closed (textarea blurred), no dictation, no sheet/menu/dialog. - // The short delay bridges focus moving between composer controls. - const mobileComposerBusy = mobileTextareaFocused - || mobileOverlayHostBusy - || mobileDictationActive - || Boolean(mobileControlsPanel) - || mobileAttachMenuOpen - || mobileDraftPicker !== null - || issuePickerOpen - || prPickerOpen - || isDragging; - React.useEffect(() => { - if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return; - const timer = window.setTimeout(() => { - // Authoritative DOM check: the React focus state can lag a - // programmatic refocus (overlay-close keyboard restore). Collapsing - // would unmount the focused textarea and kill the keyboard. - if (document.activeElement === textareaRef.current) return; - mobileExpandIntentRef.current = null; - setMobileComposerExpanded(false); - setExpandedInput(false); - }, 250); - return () => window.clearTimeout(timer); - }, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]); - - const mobileComposerBusyRef = React.useRef(false); - mobileComposerBusyRef.current = mobileComposerBusy; - - // Browser counterpart of Capacitor's oc-keyboard-open root class (which is - // driven by native keyboard events): the focused composer textarea is the - // best keyboard proxy a browser has. CSS keyed on it hides the draft - // starters while typing, mirroring the native app. - React.useEffect(() => { - if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return; - const root = document.documentElement; - if (mobileTextareaFocused) { - root.classList.add('oc-browser-keyboard-open'); - } else { - root.classList.remove('oc-browser-keyboard-open'); - // Installed PWA (standalone): after the keyboard dismisses, WebKit - // can leave the layout viewport stuck smaller / panned (content - // shifted up with a dead strip at the bottom) until something - // forces it to recompute. A zero scroll after the keyboard's exit - // animation settles snaps it back; harmless when nothing is stuck. - if (window.matchMedia?.('(display-mode: standalone)')?.matches) { - window.setTimeout(() => { - if (root.classList.contains('oc-browser-keyboard-open')) return; - window.scrollTo(0, 0); - document.body.scrollTop = 0; - root.scrollTop = 0; - }, 350); - } - } - return () => root.classList.remove('oc-browser-keyboard-open'); - }, [isMobile, mobileTextareaFocused]); - - // Capacitor: collapse in the SAME frame the keyboard starts hiding. The - // hide choreography dispatches oc:keyboard-intent BEFORE restoring the - // shell layout and measuring the chat compensation; flushSync commits the - // pill swap first, so keyboard land + composer shrink are measured (and - // compensated) as ONE motion instead of a two-step staircase. The delayed - // effect above stays as the fallback for non-Capacitor and for overlays - // closing without a keyboard transition. - React.useEffect(() => { - if (!isMobile || typeof window === 'undefined') return; - const handleIntent = (event: Event) => { - const detail = (event as CustomEvent<{ open?: boolean }>).detail; - if (!detail || detail.open !== false) return; - if (!mobileComposerExpandedRef.current) return; - // Something still holds the composer open (dictation, an overlay - // that closed the keyboard, drag) — the fallback path handles it. - if (mobileComposerBusyRef.current) return; - mobileExpandIntentRef.current = null; - flushSync(() => { - setMobileComposerExpanded(false); - setExpandedInput(false); - }); - }; - window.addEventListener('oc:keyboard-intent', handleIntent); - return () => window.removeEventListener('oc:keyboard-intent', handleIntent); - }, [isMobile, setExpandedInput]); // Reset the picker search whenever a draft picker sheet opens/closes. React.useEffect(() => { setMobileDraftPickerQuery(''); }, [mobileDraftPicker]); - - // ── Composer drag handle (mobile): swipe up = fullscreen, swipe down = - // leave fullscreen or dismiss the keyboard. ──────────────────────────── - const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => { - const touch = event.touches.item(0); - composerHandleTouchRef.current = touch ? { startY: touch.clientY, fired: false } : null; - }, []); - const handleComposerHandleTouchMove = React.useCallback((event: React.TouchEvent) => { - const state = composerHandleTouchRef.current; - if (!state || state.fired) return; - const touch = event.touches.item(0); - if (!touch) return; - const dy = touch.clientY - state.startY; - if (dy <= -28) { - state.fired = true; - if (!isExpandedInput) setExpandedInput(true); - } else if (dy >= 28) { - state.fired = true; - if (isExpandedInput) { - setExpandedInput(false); - } else { - textareaRef.current?.blur(); - } - } - }, [isExpandedInput, setExpandedInput]); - const handleComposerHandleTouchEnd = React.useCallback(() => { - composerHandleTouchRef.current = null; - }, []); - - // Fullscreen composer in a mobile BROWSER: the page layout doesn't shrink - // for the keyboard there — Safari pans/scrolls instead, so any flow-based - // sizing ends up partly off-screen or under the keyboard (the chat page is - // usually already panned when fullscreen is entered). Pin the form to the - // VISUAL viewport directly: fixed at its offset with its height, updated - // as the browser pans. Capacitor is excluded — its shell already resizes - // via the keyboard choreography. - const composerFormRef = React.useRef(null); - React.useLayoutEffect(() => { - if (!isMobile || !isMobileExpanded || isCapacitorApp()) return; - const vv = window.visualViewport; - const form = composerFormRef.current; - const textarea = textareaRef.current; - if (!vv || !form) return; - // The form is trapped inside lower stacking contexts (the composer - // wrapper's z-10), so it cannot out-stack the app header with z-index - // alone — hide the header for the duration via a root class instead. - document.documentElement.classList.add('oc-browser-kb-fullscreen'); - const apply = () => { - const top = Math.max(0, Math.floor(vv.offsetTop)); - // Same stale-visualViewport guard as the draft pin below: when the - // layout viewport is keyboard-resized (interactive-widget), its - // clientHeight is the authoritative above-keyboard height. - const layoutHeight = document.documentElement.clientHeight; - form.style.position = 'fixed'; - form.style.left = '0'; - form.style.right = '0'; - form.style.top = `${top}px`; - form.style.height = `${Math.floor(Math.min(vv.height, layoutHeight - top))}px`; - form.style.zIndex = '40'; - form.style.background = 'var(--background)'; - }; - apply(); - vv.addEventListener('resize', apply); - vv.addEventListener('scroll', apply); - window.addEventListener('resize', apply); - window.addEventListener('scroll', apply, true); - return () => { - vv.removeEventListener('resize', apply); - vv.removeEventListener('scroll', apply); - window.removeEventListener('resize', apply); - window.removeEventListener('scroll', apply, true); - document.documentElement.classList.remove('oc-browser-kb-fullscreen'); - form.style.position = ''; - form.style.left = ''; - form.style.right = ''; - form.style.top = ''; - form.style.height = ''; - form.style.zIndex = ''; - form.style.background = ''; - // Back in flow: the browser panned/scrolled for the fullscreen - // session and won't re-reveal the (still focused) field on its own, - // which left the composer parked behind the keyboard. - requestAnimationFrame(() => { - if (textarea && document.activeElement === textarea) { - textarea.scrollIntoView({ block: 'nearest' }); - } - }); - }; - }, [isMobile, isMobileExpanded]); - - // Draft screen in a mobile BROWSER with the keyboard open: Safari's own - // focused-field reveal is unreliable there (leaving the composer behind - // the keyboard, e.g. after collapsing from fullscreen), so the NORMAL - // composer is pinned to the visual viewport too — anchored to its visible - // bottom at its natural height. The chat screen doesn't need this (its - // reveal works) and Capacitor has the keyboard choreography. - React.useLayoutEffect(() => { - if (!isMobile || isCapacitorApp()) return; - if (!newSessionDraftOpen || isMobileExpanded || !mobileTextareaFocused) return; - const vv = window.visualViewport; - const form = composerFormRef.current; - if (!vv || !form) return; - // Keep the in-flow horizontal geometry (page paddings) while fixed. - const rect = form.getBoundingClientRect(); - form.style.position = 'fixed'; - form.style.left = `${Math.floor(rect.left)}px`; - form.style.width = `${Math.floor(rect.width)}px`; - form.style.zIndex = '40'; - form.style.background = 'var(--background)'; - // Safari's visualViewport events are unreliable mid keyboard pan (they - // can simply not fire), so track the pan with a rAF loop instead — - // cheap math per frame, a style write only when the value changes. - let lastTop = Number.NaN; - let frame = 0; - const track = () => { - // iOS standalone (PWA) can serve stale visualViewport metrics after - // the keyboard rises (full pre-keyboard height, intermittently), - // parking the form behind the keyboard. When interactive-widget - // resizes the layout viewport, documentElement.clientHeight is the - // true above-keyboard bottom — anchor to whichever is smaller. In - // pan-mode browsers clientHeight stays full height, so the min - // keeps the visual-viewport anchor there. - const layoutBottom = document.documentElement.clientHeight; - const vvBottom = vv.offsetTop + vv.height; - const top = Math.max(0, Math.floor(Math.min(vvBottom, layoutBottom) - form.offsetHeight)); - if (top !== lastTop) { - lastTop = top; - form.style.top = `${top}px`; - } - frame = requestAnimationFrame(track); - }; - track(); - return () => { - cancelAnimationFrame(frame); - form.style.position = ''; - form.style.left = ''; - form.style.width = ''; - form.style.top = ''; - form.style.zIndex = ''; - form.style.background = ''; - }; - }, [isMobile, isMobileExpanded, newSessionDraftOpen, mobileTextareaFocused]); - - // Shared drag handle: rendered at the top of the full composer AND inside - // the dictation overlay, so swipe-expand/collapse works in Listening mode. - // Memoized so the always-mounted dictation instance's memo stays effective. - const mobileComposerHandle = React.useMemo(() => isMobile ? ( -