Composer: CodeMirror editor, unified prompt language, ChatInput decomposition (#2419)

* 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
This commit is contained in:
Bohdan Triapitsyn
2026-07-27 22:21:38 +03:00
committed by GitHub
parent 8801d69c66
commit 005b2e61b0
51 changed files with 8433 additions and 3671 deletions
@@ -995,7 +995,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ 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).
<div className="relative flex h-full flex-col bg-background">
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
@@ -1020,7 +1020,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="relative flex h-full flex-col bg-background">
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
@@ -1041,7 +1041,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
);
}
return (
<div className="relative flex flex-col h-full bg-background">
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
@@ -1099,7 +1099,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
return (
// No transform here either — same fixed-positioning constraint as the
// draft branch above.
<div className="relative flex flex-col h-full bg-background">
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
@@ -1131,7 +1131,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
}
return (
<div className="relative flex flex-col h-full bg-background">
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
import { describe, expect, test } from 'bun:test';
import {
buildHighlightParts,
isFenceClose,
matchFenceOpen,
mentionRangesToHighlightRanges,
resolveHighlightSegments,
tokenizeMarkdown,
type HighlightRange,
} from '../composerHighlight';
/**
* Characterization tests: they record what the composer highlighter does TODAY,
* before the CodeMirror migration replaces the textarea + mirror overlay. The
* token grammar must survive that move unchanged, so these assert token spans
* and styles rather than rendered classes.
*/
/** Compact view of a range: the exact substring it covers, plus its style. */
const spans = (text: string, ranges: HighlightRange[]) =>
ranges.map((range) => [text.slice(range.start, range.end), range.style] as const);
const tokenize = (text: string) => spans(text, tokenizeMarkdown(text));
describe('tokenizeMarkdown — block constructs', () => {
test('headings split into marker and content', () => {
expect(tokenize('## Title')).toEqual([
['##', 'marker'],
['Title', 'heading'],
]);
});
test('heading requires a space after the hashes', () => {
expect(tokenize('##Title')).toEqual([]);
});
test('seven hashes is not a heading', () => {
expect(tokenize('####### too deep')).toEqual([]);
});
test('blockquote marker is dimmed and content styled', () => {
expect(tokenize('> quoted')).toEqual([
['>', 'marker'],
['quoted', 'blockquote'],
]);
});
test('nested blockquote markers collapse into one marker range', () => {
expect(tokenize('>> deep')).toEqual([
['>>', 'marker'],
['deep', 'blockquote'],
]);
});
test('bullet and ordered list markers', () => {
expect(tokenize('- one')).toEqual([['-', 'listMarker']]);
expect(tokenize('* one')).toEqual([['*', 'listMarker']]);
expect(tokenize('+ one')).toEqual([['+', 'listMarker']]);
expect(tokenize('1. one')).toEqual([['1.', 'listMarker']]);
expect(tokenize('2) two')).toEqual([['2)', 'listMarker']]);
});
test('indented list markers keep their offset', () => {
expect(tokenize(' - nested')).toEqual([['-', 'listMarker']]);
});
test('a list marker needs trailing whitespace', () => {
expect(tokenize('-nodash')).toEqual([]);
});
});
describe('tokenizeMarkdown — fenced code', () => {
test('every line of a fence is codeFence, including the delimiters', () => {
const text = '```ts\nconst a = 1;\n```';
expect(tokenize(text)).toEqual([
['```ts', 'codeFence'],
['const a = 1;', 'codeFence'],
['```', 'codeFence'],
]);
});
test('tilde fences work the same way', () => {
expect(tokenize('~~~\nbody\n~~~')).toEqual([
['~~~', 'codeFence'],
['body', 'codeFence'],
['~~~', 'codeFence'],
]);
});
test('markdown inside a fence is not tokenized', () => {
expect(tokenize('```\n# not a heading\n- not a list\n```')).toEqual([
['```', 'codeFence'],
['# not a heading', 'codeFence'],
['- not a list', 'codeFence'],
['```', 'codeFence'],
]);
});
test('an unterminated fence swallows the rest of the text', () => {
expect(tokenize('```\nstill open\n# nope')).toEqual([
['```', 'codeFence'],
['still open', 'codeFence'],
['# nope', 'codeFence'],
]);
});
test('an info-string line does not close its own fence', () => {
expect(isFenceClose('```js', '```')).toBe(false);
expect(isFenceClose('```', '```')).toBe(true);
expect(isFenceClose(' ``` ', '```')).toBe(true);
});
test('a closing fence may be longer than the opening run', () => {
expect(isFenceClose('````', '```')).toBe(true);
expect(isFenceClose('``', '```')).toBe(false);
});
test('matchFenceOpen reports marker and language', () => {
expect(matchFenceOpen('```ts')).toEqual({ marker: '```', lang: 'ts' });
expect(matchFenceOpen('~~~~')).toEqual({ marker: '~~~~', lang: '' });
expect(matchFenceOpen('``')).toBeNull();
});
});
describe('tokenizeMarkdown — inline spans', () => {
test('inline code covers the backticks as well as the content', () => {
expect(tokenize('run `bun test` now')).toEqual([['`bun test`', 'code']]);
});
test('a double-backtick run is closed by a matching run', () => {
expect(tokenize('``a ` b``')).toEqual([['``a ` b``', 'code']]);
});
test('an unclosed backtick is left as plain text', () => {
expect(tokenize('a ` b')).toEqual([]);
});
test('links split into markers, text and url', () => {
expect(tokenize('[docs](https://x.dev)')).toEqual([
['[', 'marker'],
['docs', 'link'],
['](', 'marker'],
['https://x.dev', 'linkUrl'],
[')', 'marker'],
]);
});
test('an empty link text emits no link range', () => {
expect(tokenize('[](url)')).toEqual([
['[', 'marker'],
['](', 'marker'],
['url', 'linkUrl'],
[')', 'marker'],
]);
});
test('inline spans are scanned inside headings, quotes and list items', () => {
expect(tokenize('# see `code`')).toEqual([
['#', 'marker'],
['see `code`', 'heading'],
['`code`', 'code'],
]);
expect(tokenize('> see `code`')).toEqual([
['>', 'marker'],
['see `code`', 'blockquote'],
['`code`', 'code'],
]);
expect(tokenize('- see `code`')).toEqual([
['-', 'listMarker'],
['`code`', 'code'],
]);
});
test('a bare ~path is not markdown — the language layer owns it', () => {
expect(tokenize('~/repos/ocb/README.md')).toEqual([]);
});
});
describe('tokenizeMarkdown — emphasis', () => {
test('double delimiters are strong, single are emphasis', () => {
expect(tokenize('**bold**')).toEqual([
['**', 'marker'],
['bold', 'strong'],
['**', 'marker'],
]);
expect(tokenize('*slanted*')).toEqual([
['*', 'marker'],
['slanted', 'emphasis'],
['*', 'marker'],
]);
});
test('triple delimiters are strong AND emphasis over the same span', () => {
expect(tokenize('***both***')).toEqual([
['***', 'marker'],
['both', 'strong'],
['both', 'emphasis'],
['***', 'marker'],
]);
expect(tokenize('___both___').map(([, style]) => style))
.toEqual(['marker', 'strong', 'emphasis', 'marker']);
});
test('the underscore spellings work too', () => {
expect(tokenize('_slanted_').map(([, style]) => style))
.toEqual(['marker', 'emphasis', 'marker']);
expect(tokenize('__bold__').map(([, style]) => style))
.toEqual(['marker', 'strong', 'marker']);
});
test('arithmetic is not emphasis', () => {
expect(tokenize('2 * 3 * 4')).toEqual([]);
expect(tokenize('a * b')).toEqual([]);
});
test('an identifier is not emphasis', () => {
expect(tokenize('foo_bar_baz')).toEqual([]);
expect(tokenize('SCREAMING_SNAKE_CASE')).toEqual([]);
});
test('an underscore span still works between words', () => {
expect(tokenize('say _this_ loudly').map(([text]) => text))
.toEqual(['_', 'this', '_']);
});
test('a delimiter with nothing after it opens nothing', () => {
expect(tokenize('trailing * ')).toEqual([]);
expect(tokenize('ends with *')).toEqual([]);
});
test('an unclosed delimiter is left as plain text', () => {
expect(tokenize('*never closed')).toEqual([]);
});
test('emphasis does not span lines', () => {
expect(tokenize('*open\nclose*')).toEqual([]);
});
test('inline spans inside emphasis are still scanned', () => {
expect(tokenize('**see `code`**').map(([text, style]) => `${text}:${style}`))
.toContain('`code`:code');
});
test('a list marker is not read as emphasis', () => {
expect(tokenize('* item')).toEqual([['*', 'listMarker']]);
});
test('emphasis inside a heading keeps both', () => {
const text = '# A **strong** title';
const styles = tokenize(text).map(([, style]) => style);
expect(styles).toContain('heading');
expect(styles).toContain('strong');
});
});
describe('tokenizeMarkdown — attention', () => {
test('a !!! line marks its content', () => {
expect(tokenize('!!! important')).toEqual([
['!!!', 'marker'],
['important', 'attention'],
]);
});
test('it needs a space after the marks', () => {
expect(tokenize('!!!important')).toEqual([]);
});
test('an emphatic sentence is not an attention line', () => {
expect(tokenize('that is wild!!!')).toEqual([]);
expect(tokenize('!! close')).toEqual([]);
});
test('inline spans inside an attention line are scanned', () => {
expect(tokenize('!!! check `this`').map(([, style]) => style))
.toContain('code');
});
});
describe('tokenizeMarkdown — offsets across lines', () => {
test('ranges are absolute offsets into the whole text', () => {
const text = 'intro\n# Head\n- item';
const ranges = tokenizeMarkdown(text);
for (const range of ranges) {
expect(text.slice(range.start, range.end).length).toBe(range.end - range.start);
}
expect(spans(text, ranges)).toEqual([
['#', 'marker'],
['Head', 'heading'],
['-', 'listMarker'],
]);
});
test('empty input yields no ranges', () => {
expect(tokenizeMarkdown('')).toEqual([]);
});
});
describe('mentionRangesToHighlightRanges', () => {
test('file and agent mentions map to their own styles', () => {
expect(mentionRangesToHighlightRanges([
{ start: 0, end: 5, kind: 'file' },
{ start: 6, end: 9, kind: 'agent' },
])).toEqual([
{ start: 0, end: 5, style: 'mentionFile' },
{ start: 6, end: 9, style: 'mentionAgent' },
]);
});
});
describe('resolveHighlightSegments', () => {
test('segments tile the whole text without gaps or overlap', () => {
const text = '# Title\nsee `code` and more';
const segments = resolveHighlightSegments(text, tokenizeMarkdown(text));
expect(segments[0].start).toBe(0);
expect(segments[segments.length - 1].end).toBe(text.length);
for (let i = 1; i < segments.length; i += 1) {
expect(segments[i].start).toBe(segments[i - 1].end);
}
});
test('no text and no ranges resolve to nothing', () => {
expect(resolveHighlightSegments('', [{ start: 0, end: 1, style: 'code' }])).toEqual([]);
expect(resolveHighlightSegments('abc', [])).toEqual([]);
});
test('adjacent same-class stretches are merged into one segment', () => {
const segments = resolveHighlightSegments('abcdef', [
{ start: 0, end: 3, style: 'code' },
{ start: 3, end: 6, style: 'code' },
]);
expect(segments).toHaveLength(1);
expect(segments[0].start).toBe(0);
expect(segments[0].end).toBe(6);
});
test('segments agree with the parts the overlay renders', () => {
const text = 'a `b` c';
const ranges = tokenizeMarkdown(text);
const segments = resolveHighlightSegments(text, ranges);
const parts = buildHighlightParts(text, ranges);
expect(parts!.map((part) => part.text))
.toEqual(segments.map((segment) => text.slice(segment.start, segment.end)));
expect(parts!.map((part) => part.className))
.toEqual(segments.map((segment) => segment.className));
});
});
describe('resolveHighlightSegments — additive styles', () => {
/**
* A segment carries one class string, so weight and colour cannot be
* chosen between: emphasis composes onto whatever construct it sits in.
*/
test('emphasis inside a heading keeps the heading colour and gains weight', () => {
const text = '# A **strong** title';
const segment = resolveHighlightSegments(text, tokenizeMarkdown(text))
.find((candidate) => text.slice(candidate.start, candidate.end) === 'strong');
expect(segment!.className.includes('font-semibold')).toBe(true);
expect(segment!.className.includes('--syntax-keyword')).toBe(true);
});
test('emphasis on its own still renders over the default text colour', () => {
const text = '*slanted*';
const segment = resolveHighlightSegments(text, tokenizeMarkdown(text))
.find((candidate) => text.slice(candidate.start, candidate.end) === 'slanted');
expect(segment!.className.includes('italic')).toBe(true);
});
test('a style is not repeated when two identical ranges overlap', () => {
const parts = resolveHighlightSegments('abcd', [
{ start: 0, end: 4, style: 'strong' },
{ start: 0, end: 4, style: 'strong' },
]);
expect(parts[0].className.split('font-semibold').length - 1).toBe(1);
});
});
describe('buildHighlightParts', () => {
test('returns null when there is nothing to highlight', () => {
expect(buildHighlightParts('', [])).toBeNull();
expect(buildHighlightParts('plain text', [])).toBeNull();
});
test('covers the full text and preserves it exactly', () => {
const text = '# Title\nplain `code` tail';
const parts = buildHighlightParts(text, tokenizeMarkdown(text));
expect(parts).not.toBeNull();
expect(parts!.map((part) => part.text).join('')).toBe(text);
});
test('adjacent parts sharing a class are coalesced', () => {
const parts = buildHighlightParts('abcdef', [
{ start: 0, end: 3, style: 'code' },
{ start: 3, end: 6, style: 'code' },
]);
expect(parts).toHaveLength(1);
expect(parts![0].text).toBe('abcdef');
});
test('higher priority wins on overlap — a mention beats inline code', () => {
const text = '`@a/b.ts`';
const parts = buildHighlightParts(text, [
{ start: 0, end: text.length, style: 'code' },
{ start: 1, end: text.length - 1, style: 'mentionFile' },
]);
expect(parts!.map((part) => part.text)).toEqual(['`', '@a/b.ts', '`']);
expect(parts![1].className).toBe(parts![1].className);
expect(parts![0].className).not.toBe(parts![1].className);
});
test('equal priority resolves to the earliest range in input order', () => {
const parts = buildHighlightParts('abcd', [
{ start: 0, end: 4, style: 'mentionFile' },
{ start: 0, end: 4, style: 'mentionAgent' },
]);
expect(parts).toHaveLength(1);
expect(parts![0].className).toBe(
buildHighlightParts('abcd', [{ start: 0, end: 4, style: 'mentionFile' }])![0].className,
);
});
test('an explicit priority overrides the style table', () => {
const parts = buildHighlightParts('abcd', [
{ start: 0, end: 4, style: 'mentionFile' },
{ start: 0, end: 4, style: 'marker', priority: 999 },
]);
expect(parts![0].className).toBe(
buildHighlightParts('abcd', [{ start: 0, end: 4, style: 'marker' }])![0].className,
);
});
test('an explicit className overrides the style class', () => {
const parts = buildHighlightParts('abcd', [
{ start: 0, end: 4, style: 'code', className: 'custom-class' },
]);
expect(parts).toEqual([{ text: 'abcd', className: 'custom-class' }]);
});
test('zero-width ranges are ignored', () => {
const parts = buildHighlightParts('abcd', [{ start: 2, end: 2, style: 'code' }]);
expect(parts).toHaveLength(1);
expect(parts![0].text).toBe('abcd');
});
});
@@ -0,0 +1,126 @@
# Composer
The chat composer: the prompt language, the editor that renders it, and
everything between typing and sending.
`ChatInput.tsx` (one directory up) is the orchestrator. It holds the composer's
own state and wires these modules together; it should not grow logic that
belongs to one of them.
## Layers
| Directory | Owns |
|---|---|
| `language/` | What the text *means*: `@` references, `/` and `#` tokens, markdown, and which picker a caret asks for |
| `editor/` | The CodeMirror view that renders the language and owns the caret |
| `state/` | Composer state with a lifecycle: drafts, mobile shell, history, popup placement, draft targeting |
| `submit/` | Turning what the user has into what gets sent |
| `attachments/` | Files: paths, drop payloads |
| `ui/` | Presentation |
| `text.ts` | How inserted text meets the text already there |
## The prompt language
`language/` is the single source of truth for composer syntax. Everything that
needs to know what a token means — highlighting, send-time resolution, and the
autocomplete triggers — goes through it.
**This is the invariant that matters most in this module.** Before it existed,
the `@` rule was written four times with divergent cleanup and the `/` rule
three times with different valid character sets, so a token could be painted as
a reference and then not resolve as one. Adding a construct meant finding every
copy.
- `mentions.ts``@` references. The `start..end` span is the reference
itself and is what gets highlighted; in `see @a/b.ts,` the comma is sentence
punctuation, not part of the file being referenced. Mentions are plain
editable text: deleting a character edits the token and reopens the mention
picker, the same way `/skill` tokens behave — not an atomic delete.
- `prefixTokens.ts``/command`, `/skill`, `#snippet`. Scanning is deliberately
generous; **membership in the command, skill or snippet registry is the
authority**, not the pattern. An unknown `/token` stays plain prose.
- `triggers.ts` — which picker a caret position asks for. Exactly one can be
active, with precedence `command > skill > snippet > mention`.
- `tokenize.ts` — one pass producing every highlight range. Adding a construct
to the language means adding it here, once.
## The editor
`editor/` wraps CodeMirror. The document is a plain string: `getValue()` is
exactly what gets sent, so nothing downstream serializes a rich document model
back into a prompt.
The composer previously painted a transparent `<textarea>` over a mirror
`<div>`. That restricted highlighting to styles which do not change glyph
advance width — colour, background, underline — because anything else made the
mirror drift out from under the caret. Bold and italic were impossible, and the
overlay was disabled outright on mobile, where wrapped text drifted anyway.
**Those constraints are gone**; adding a width-affecting style is now a
question of design, not of feasibility.
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
keeps typing on the drawn-selection code path, and removing it makes
CodeMirror enforce cursor association on the native selection, which iOS
answers with severe input lag. Every device also layers
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
the native selection, and — only while a range is selected — the native caret,
hiding the painted layers those replace. The native selection is the one that
shows for two reasons: the painted layer sits behind the content, so tokens
with their own background (inline code, fences) cover it completely; and
iOS's selection drag handles attach to the visible native selection and take
their colour from the caret, so a transparent caret means invisible handles.
The range-only caret scoping is load-bearing — a native caret visible while
typing makes WebKit re-render its caret UI after every keystroke, felt as
severe input lag. The selection tint comes from `--primary`, not the selection
token:
themes define `--interactive-selection` with its own alpha, so a translucent
mix of it is nearly invisible.
`composerLanguage.ts` retokenizes the whole document on every change. The
composer holds a prompt, not a source file: it is short enough that a full pass
is cheaper and far simpler than incremental mapping, and it keeps the editor
and the send path reading the same grammar.
## Ordering rules worth knowing
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
inline comments and context into OpenCode's one-primary-plus-parts shape. The
oldest queued message becomes primary; **inline comments attach to the last
body the user authored** rather than becoming their own part; PR instructions
precede the PR diff.
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
session) identity. Writes are debounced while typing but forced at every edge
where the page may stop running, because a pending timer is not a saved
draft. Two orderings are load-bearing: the debounced write is skipped once
while a draft is being restored, and a deleted draft's empty signature is
recorded before a queued write could resurrect it.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
## Mobile
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
mostly not state machines but 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.
**Every timeout and `flushSync` in them has a reason recorded next to it, and
none of them is verifiable outside a real device.** Change them only against
hardware.
## Testing
The package has no DOM test environment, so coverage stops at the state and
logic layers: the language, the submit assembly, path and drop handling, text
splicing, message history, and the CodeMirror language extension at the
`EditorState` level.
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
tests** and are verified by hand. Do not report a change to them as validated
on the strength of type-check and unit tests.
Run tests per file (`bun test <path>`): `mock.module` is process-global, so
suites that install module mocks are order-dependent.
@@ -0,0 +1,121 @@
import { describe, expect, test } from 'bun:test';
import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from '../text';
describe('appendWithLineBreaks', () => {
test('separates the block with a blank line and ends with one', () => {
expect(appendWithLineBreaks('intro', 'body')).toBe('intro\n\nbody\n\n');
});
test('does not stack separators that are already there', () => {
expect(appendWithLineBreaks('intro\n\n', 'body')).toBe('intro\n\nbody\n\n');
expect(appendWithLineBreaks('intro\n', 'body')).toBe('intro\n\nbody\n\n');
});
test('an empty base needs no separator', () => {
expect(appendWithLineBreaks('', 'body')).toBe('body\n\n');
});
test('trailing breaks in the inserted block are normalized, not doubled', () => {
expect(appendWithLineBreaks('a', 'b\n')).toBe('a\n\nb\n\n');
expect(appendWithLineBreaks('a', 'b\n\n')).toBe('a\n\nb\n\n');
});
});
describe('appendInlineText', () => {
test('joins with a single space and leaves the caret room', () => {
expect(appendInlineText('hello', 'world')).toBe('hello world ');
});
test('does not double an existing space', () => {
expect(appendInlineText('hello ', 'world')).toBe('hello world ');
expect(appendInlineText('hello\n', 'world')).toBe('hello\nworld ');
});
test('an empty base yields just the text', () => {
expect(appendInlineText('', 'world')).toBe('world ');
});
test('blank additions are ignored', () => {
expect(appendInlineText('hello', ' ')).toBe('hello');
expect(appendInlineText('hello', '')).toBe('hello');
});
test('the addition is trimmed before joining', () => {
expect(appendInlineText('hello', ' world ')).toBe('hello world ');
});
});
describe('withInlineInsertionBoundaries', () => {
test('pads between two words', () => {
expect(withInlineInsertionBoundaries('mid', 'left', 'right')).toBe(' mid ');
});
test('adds nothing at the very start or end', () => {
expect(withInlineInsertionBoundaries('mid', '', '')).toBe('mid');
});
test('respects whitespace already present', () => {
expect(withInlineInsertionBoundaries('mid', 'left ', ' right')).toBe('mid');
});
test('no space after an opening bracket', () => {
expect(withInlineInsertionBoundaries('mid', '(', 'x')).toBe('mid ');
expect(withInlineInsertionBoundaries('mid', '[', 'x')).toBe('mid ');
});
test('no space before a closing bracket or sentence punctuation', () => {
expect(withInlineInsertionBoundaries('mid', 'x', ')')).toBe(' mid');
expect(withInlineInsertionBoundaries('mid', 'x', '.')).toBe(' mid');
expect(withInlineInsertionBoundaries('mid', 'x', ', rest')).toBe(' mid');
});
test('empty content stays empty', () => {
expect(withInlineInsertionBoundaries('', 'left', 'right')).toBe('');
});
});
describe('buildImagePasteInsertion', () => {
test('a citation pasted alone is the whole insertion', () => {
expect(buildImagePasteInsertion('', '[shot.png]')).toBe('[shot.png]');
});
test('text pasted with the image keeps the citation after it', () => {
expect(buildImagePasteInsertion('look', '[shot.png]')).toBe('look [shot.png]');
});
test('an existing trailing space is not doubled', () => {
expect(buildImagePasteInsertion('look ', '[shot.png]')).toBe('look [shot.png]');
});
});
describe('shouldWrapSelectionAsLink', () => {
test('a URL pasted over selected text becomes a link', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', 'docs')).toBe(true);
expect(shouldWrapSelectionAsLink('mailto:a@b.c', 'mail me')).toBe(true);
});
test('non-URLs are pasted normally', () => {
expect(shouldWrapSelectionAsLink('just text', 'docs')).toBe(false);
expect(shouldWrapSelectionAsLink('ftp://x.dev', 'docs')).toBe(false);
});
test('a URL containing whitespace is not one', () => {
expect(shouldWrapSelectionAsLink('https://x.dev y', 'docs')).toBe(false);
});
test('an empty or blank selection has nothing to wrap', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '')).toBe(false);
expect(shouldWrapSelectionAsLink('https://x.dev', ' ')).toBe(false);
});
test('a selection that is already a link is not nested', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
});
});
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test';
import {
collectDroppedFileUris,
collectDroppedFiles,
hasDraggedFiles,
INTERNAL_FILE_PATH_TYPE,
} from '../dataTransfer';
/** A minimal DataTransfer stand-in; only what the helpers read is modelled. */
function fakeTransfer(options: {
types?: string[];
data?: Record<string, string>;
files?: File[];
items?: Array<{ kind: string; file?: File }>;
throwOnGetData?: boolean;
}): DataTransfer {
const data = options.data ?? {};
return {
types: options.types ?? Object.keys(data),
files: options.files ?? [],
items: (options.items ?? []).map((item) => ({
kind: item.kind,
getAsFile: () => item.file ?? null,
})),
getData: (type: string) => {
if (options.throwOnGetData) throw new Error('unavailable during dragover');
return data[type] ?? '';
},
} as unknown as DataTransfer;
}
const file = (name: string) => new File(['x'], name, { type: 'text/plain' });
describe('hasDraggedFiles', () => {
test('real files are recognized', () => {
expect(hasDraggedFiles(fakeTransfer({ files: [file('a.txt')] }))).toBe(true);
});
test('a declared file-bearing type is enough', () => {
expect(hasDraggedFiles(fakeTransfer({ types: ['Files'] }))).toBe(true);
expect(hasDraggedFiles(fakeTransfer({ types: ['text/uri-list'] }))).toBe(true);
expect(hasDraggedFiles(fakeTransfer({ types: ['CodeFiles'] }))).toBe(true);
});
test('an internal file-tree drag is recognized', () => {
expect(hasDraggedFiles(fakeTransfer({ types: [INTERNAL_FILE_PATH_TYPE] }))).toBe(true);
});
test('a VS Code tree type is recognized by prefix', () => {
expect(hasDraggedFiles(fakeTransfer({ types: ['application/vnd.code.tree.explorer'] })))
.toBe(true);
});
test('type matching is case-insensitive', () => {
expect(hasDraggedFiles(fakeTransfer({ types: ['FILES'] }))).toBe(true);
});
test('falls back to scanning payloads when the types say nothing useful', () => {
expect(hasDraggedFiles(fakeTransfer({
types: ['application/unknown'],
data: { 'text/plain': '/repo/a.ts' },
}))).toBe(true);
});
test('dragged text is not a file drag', () => {
expect(hasDraggedFiles(fakeTransfer({
types: ['text/plain'],
data: { 'text/plain': 'just some words' },
}))).toBe(false);
});
test('a missing transfer is not a file drag', () => {
expect(hasDraggedFiles(null)).toBe(false);
expect(hasDraggedFiles(undefined)).toBe(false);
});
test('an unreadable payload does not abort the scan', () => {
expect(hasDraggedFiles(fakeTransfer({
types: ['application/unknown'],
throwOnGetData: true,
}))).toBe(false);
});
});
describe('collectDroppedFiles', () => {
test('reads the files list', () => {
expect(collectDroppedFiles(fakeTransfer({ files: [file('a.txt')] })).map((f) => f.name))
.toEqual(['a.txt']);
});
test('falls back to the item list', () => {
expect(collectDroppedFiles(fakeTransfer({
items: [{ kind: 'file', file: file('b.txt') }],
})).map((f) => f.name)).toEqual(['b.txt']);
});
test('non-file items are skipped', () => {
expect(collectDroppedFiles(fakeTransfer({
items: [{ kind: 'string' }, { kind: 'file', file: file('c.txt') }],
})).map((f) => f.name)).toEqual(['c.txt']);
});
test('a file item that yields nothing is skipped', () => {
expect(collectDroppedFiles(fakeTransfer({ items: [{ kind: 'file' }] }))).toEqual([]);
});
test('an empty or missing transfer yields nothing', () => {
expect(collectDroppedFiles(fakeTransfer({}))).toEqual([]);
expect(collectDroppedFiles(null)).toEqual([]);
});
});
describe('collectDroppedFileUris', () => {
test('reads paths out of a VS Code payload', () => {
expect(collectDroppedFileUris(fakeTransfer({
data: { 'text/uri-list': 'file:///repo/a.ts' },
}))).toEqual(['file:///repo/a.ts']);
});
test('the same path across several types is returned once', () => {
expect(collectDroppedFileUris(fakeTransfer({
data: { 'text/uri-list': '/repo/a.ts', 'text/plain': '/repo/a.ts' },
}))).toEqual(['/repo/a.ts']);
});
test('a payload with no paths yields nothing', () => {
expect(collectDroppedFileUris(fakeTransfer({
data: { 'text/plain': 'words' },
}))).toEqual([]);
});
test('a transfer without getData yields nothing', () => {
expect(collectDroppedFileUris({} as DataTransfer)).toEqual([]);
expect(collectDroppedFileUris(null)).toEqual([]);
});
});
@@ -0,0 +1,177 @@
import { describe, expect, test } from 'bun:test';
import {
encodeFilePath,
isLikelyAbsolutePath,
normalizeDroppedPath,
normalizePath,
parseDroppedFileReferences,
toLikelyFileDropReference,
toProjectRelativeMentionPath,
toServerFileUrl,
} from '../filePaths';
describe('encodeFilePath', () => {
test('encodes segments but keeps separators', () => {
expect(encodeFilePath('/a/b c/d.txt')).toBe('/a/b%20c/d.txt');
});
test('backslashes become forward slashes', () => {
expect(encodeFilePath('a\\b\\c.txt')).toBe('a/b/c.txt');
});
test('a Windows drive letter is preserved unencoded', () => {
expect(encodeFilePath('C:\\Users\\me\\a b.txt')).toBe('/C:/Users/me/a%20b.txt');
});
test('special characters in a name are encoded', () => {
expect(encodeFilePath('/a/b#c?d.txt')).toBe('/a/b%23c%3Fd.txt');
});
});
describe('toServerFileUrl', () => {
test('wraps a plain path', () => {
expect(toServerFileUrl('/repo/a.ts')).toBe('file:///repo/a.ts');
});
test('an existing file URL is passed through unchanged', () => {
expect(toServerFileUrl('file:///repo/a.ts')).toBe('file:///repo/a.ts');
expect(toServerFileUrl('FILE:///repo/a.ts')).toBe('FILE:///repo/a.ts');
});
test('a Windows path becomes a valid file URL', () => {
expect(toServerFileUrl('C:\\repo\\a.ts')).toBe('file:///C:/repo/a.ts');
});
});
describe('isLikelyAbsolutePath', () => {
test('recognizes posix, UNC and Windows roots', () => {
expect(isLikelyAbsolutePath('/repo/a.ts')).toBe(true);
expect(isLikelyAbsolutePath('\\\\share\\a.ts')).toBe(true);
expect(isLikelyAbsolutePath('C:/repo')).toBe(true);
expect(isLikelyAbsolutePath('C:\\repo')).toBe(true);
});
test('relative paths are not absolute', () => {
expect(isLikelyAbsolutePath('src/a.ts')).toBe(false);
expect(isLikelyAbsolutePath('./a.ts')).toBe(false);
});
});
describe('toLikelyFileDropReference', () => {
test('accepts an absolute path and a file URL', () => {
expect(toLikelyFileDropReference('/repo/a.ts')).toBe('/repo/a.ts');
expect(toLikelyFileDropReference('file:///repo/a.ts')).toBe('file:///repo/a.ts');
});
test('strips surrounding quotes and whitespace', () => {
expect(toLikelyFileDropReference(' "/repo/a.ts" ')).toBe('/repo/a.ts');
});
test('rejects relative paths, prose and empty input', () => {
expect(toLikelyFileDropReference('src/a.ts')).toBeNull();
expect(toLikelyFileDropReference('some dropped sentence')).toBeNull();
expect(toLikelyFileDropReference(' ')).toBeNull();
});
test('rejects a multi-line value, which is a document not a path', () => {
expect(toLikelyFileDropReference('/repo/a.ts\n/repo/b.ts')).toBeNull();
});
});
describe('parseDroppedFileReferences', () => {
test('reads a single path', () => {
expect(parseDroppedFileReferences('/repo/a.ts')).toEqual(['/repo/a.ts']);
});
test('reads a newline-separated URI list', () => {
expect(parseDroppedFileReferences('file:///repo/a.ts\nfile:///repo/b.ts'))
.toEqual(['file:///repo/a.ts', 'file:///repo/b.ts']);
});
test('finds paths nested inside a JSON payload', () => {
const payload = JSON.stringify({ items: [{ resource: { path: '/repo/a.ts' } }] });
expect(parseDroppedFileReferences(payload)).toEqual(['/repo/a.ts']);
});
test('duplicates across passes are collapsed', () => {
expect(parseDroppedFileReferences('/repo/a.ts\n/repo/a.ts')).toEqual(['/repo/a.ts']);
});
test('a payload with no paths yields nothing', () => {
expect(parseDroppedFileReferences('just some text')).toEqual([]);
expect(parseDroppedFileReferences('')).toEqual([]);
});
test('deeply buried paths beyond the depth bound are not searched forever', () => {
let nested: unknown = '/repo/deep.ts';
for (let i = 0; i < 20; i += 1) nested = { nested };
expect(parseDroppedFileReferences(JSON.stringify(nested))).toEqual([]);
});
});
describe('normalizeDroppedPath', () => {
test('a plain path is returned as-is', () => {
expect(normalizeDroppedPath('/repo/a.ts')).toBe('/repo/a.ts');
});
test('a file URL is decoded back to a path', () => {
expect(normalizeDroppedPath('file:///repo/a%20b.ts')).toBe('/repo/a b.ts');
});
test('a Windows file URL drops the slash before the drive letter', () => {
expect(normalizeDroppedPath('file:///C:/repo/a.ts')).toBe('C:/repo/a.ts');
});
test('a malformed file URL still yields something usable', () => {
expect(normalizeDroppedPath('file://%%%')).toBe('%%%');
});
});
describe('normalizePath', () => {
test('trims and drops a trailing separator', () => {
expect(normalizePath(' /repo/dir/ ')).toBe('/repo/dir');
expect(normalizePath('/repo/dir///')).toBe('/repo/dir');
});
test('backslashes become forward slashes', () => {
expect(normalizePath('C:\\repo\\dir')).toBe('C:/repo/dir');
});
test('the root keeps its slash', () => {
expect(normalizePath('/')).toBe('/');
});
test('blank and non-string input yield null', () => {
expect(normalizePath('')).toBeNull();
expect(normalizePath(' ')).toBeNull();
expect(normalizePath(null)).toBeNull();
expect(normalizePath(undefined)).toBeNull();
});
});
describe('toProjectRelativeMentionPath', () => {
test('strips the project root', () => {
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '/repo')).toBe('src/a.ts');
});
test('tolerates a trailing slash on the root', () => {
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '/repo/')).toBe('src/a.ts');
});
test('a path outside the root stays absolute', () => {
expect(toProjectRelativeMentionPath('/other/a.ts', '/repo')).toBe('/other/a.ts');
});
test('a sibling directory sharing a prefix is not treated as inside', () => {
expect(toProjectRelativeMentionPath('/repo-other/a.ts', '/repo')).toBe('/repo-other/a.ts');
});
test('the root itself is returned unchanged', () => {
expect(toProjectRelativeMentionPath('/repo', '/repo')).toBe('/repo');
});
test('with no root the path is left alone', () => {
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '')).toBe('/repo/src/a.ts');
});
});
@@ -0,0 +1,92 @@
/**
* Reading a drop's payload.
*
* Hosts describe a dragged file in incompatible ways: a browser exposes real
* `File` entries, VS Code's explorer offers only proprietary data types whose
* payloads must be parsed for paths, and OpenChamber's own file tree marks an
* internal drag with a private type. These helpers answer the three questions
* the composer actually asks of a `DataTransfer`, and are pure so they can be
* exercised without a browser.
*
* `getData` throws in some hosts when called during dragover rather than drop;
* every read here is guarded so one unreadable type cannot abort the scan.
*/
import { parseDroppedFileReferences, VS_CODE_DROP_DATA_TYPES } from './filePaths';
/** Data type marking a drag that started in OpenChamber's own file tree. */
export const INTERNAL_FILE_PATH_TYPE = 'application/x-openchamber-file-path';
/** Data types that, by their presence alone, mean files are being dragged. */
const FILE_BEARING_TYPES = [
'files',
'text/uri-list',
'codefiles',
INTERNAL_FILE_PATH_TYPE,
];
function readData(dataTransfer: DataTransfer, type: string): string {
try {
return dataTransfer.getData(type);
} catch {
return '';
}
}
/**
* Whether this drag carries files at all, used to decide if the composer
* should show its drop target. Checked on dragenter/dragover, where payloads
* are often unreadable, so the declared types are the primary signal and the
* payload scan is the fallback for hosts that declare nothing useful.
*/
export function hasDraggedFiles(dataTransfer: DataTransfer | null | undefined): boolean {
if (!dataTransfer) return false;
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
if (dataTransfer.types) {
const lowerTypes = Array.from(dataTransfer.types).map((type) => type.toLowerCase());
if (FILE_BEARING_TYPES.some((type) => lowerTypes.includes(type))) return true;
if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true;
}
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
const payload = readData(dataTransfer, dataType);
if (payload && parseDroppedFileReferences(payload).length > 0) return true;
}
return false;
}
/**
* The actual `File` objects in a drop. `files` is the normal source; `items`
* covers hosts that only populate the item list.
*/
export function collectDroppedFiles(dataTransfer: DataTransfer | null | undefined): File[] {
if (!dataTransfer) return [];
const directFiles = Array.from(dataTransfer.files || []);
if (directFiles.length > 0) return directFiles;
return Array.from(dataTransfer.items || [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}
/**
* File references from a drop that carries no `File` objects VS Code hands
* over paths and expects the receiver to resolve them itself.
*/
export function collectDroppedFileUris(dataTransfer: DataTransfer | null | undefined): string[] {
if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
const extracted = new Set<string>();
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
const rawPayload = readData(dataTransfer, dataType);
if (!rawPayload) continue;
for (const candidate of parseDroppedFileReferences(rawPayload)) {
extracted.add(candidate);
}
}
return Array.from(extracted);
}
@@ -0,0 +1,176 @@
/**
* Path handling for composer attachments and dropped files.
*
* Three representations meet here: what the user sees in the prompt (a
* project-relative mention), what the OpenCode server is given (a `file://`
* URL), and what a host application hands over on drop (a native path, a
* percent-encoded URI, or a JSON payload with either buried inside).
*/
const FILE_URI_PREFIX = 'file://';
/**
* Percent-encode a path for use in a `file://` URL, leaving separators intact
* and preserving a Windows drive letter, which must not be encoded.
*/
export function 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('/');
}
/** The `file://` URL the server resolves an attachment from. */
export function toServerFileUrl(filepath: string): string {
const normalized = filepath.replace(/\\/g, '/').trim();
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
return normalized;
}
return `file://${encodeFilePath(normalized)}`;
}
/** POSIX root, UNC share, or Windows drive letter. */
export function isLikelyAbsolutePath(value: string): boolean {
return value.startsWith('/')
|| value.startsWith('\\\\')
|| /^[A-Za-z]:[\\/]/.test(value);
}
/**
* Trim and unquote a candidate, returning it only if it actually looks like a
* file reference. A multi-line value is rejected outright: it is a document,
* not a path.
*/
export function 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;
}
/** Collect every string in a nested value, bounded so a cyclic-ish payload terminates. */
function collectStringLeaves(input: unknown, output: Set<string>, 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);
}
/**
* Extract file references from a drop payload. Hosts disagree on the shape:
* a bare path, a newline-separated URI list, or JSON with the paths nested
* somewhere inside so try the text directly, then line by line, then again
* over every string found in the parsed JSON.
*/
export function parseDroppedFileReferences(rawPayload: string): string[] {
const extracted = new Set<string>();
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<string>();
collectStringLeaves(parsed, leaves);
for (const leaf of leaves) addCandidatesFromText(leaf);
} catch {
// Not JSON; the direct and line-wise passes already covered it.
}
return Array.from(extracted);
}
/** Turn a dropped `file://` URI back into a plain path. */
export function normalizeDroppedPath(rawPath: string): string {
const input = rawPath.trim();
if (!input.toLowerCase().startsWith(FILE_URI_PREFIX)) {
return input;
}
try {
let pathname = decodeURIComponent(new URL(input).pathname || '');
// file:///C:/... parses with a leading slash before the drive letter.
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;
}
}
}
/**
* Normalize a directory or file path for comparison: forward slashes, no
* trailing separator. Returns null for anything blank, so callers can treat
* "no path" and "unusable path" the same way.
*/
export function 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;
}
/**
* Express an absolute path relative to the project root, so the prompt carries
* the path the user recognizes. Paths outside the root are left absolute.
*/
export function toProjectRelativeMentionPath(absolutePath: string, root: string): string {
const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalizedRoot) return normalizedAbsolutePath;
if (normalizedAbsolutePath === normalizedRoot) return normalizedAbsolutePath;
const rootWithSlash = `${normalizedRoot}/`;
return normalizedAbsolutePath.startsWith(rootWithSlash)
? normalizedAbsolutePath.slice(rootWithSlash.length)
: normalizedAbsolutePath;
}
/** Data transfer types VS Code uses when dragging from its explorer. */
export const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles',
'codefiles',
'application/vnd.code.tree',
'application/vnd.code.tree.explorer',
'text/uri-list',
'text/plain',
];
@@ -0,0 +1,525 @@
/**
* The composer's text editor.
*
* This replaces the transparent-textarea-over-mirror-div arrangement the
* composer used before. That arrangement could only paint styles which do not
* change glyph advance width colour, background, underline because any
* metric change made the mirror drift out from under the caret. Bold, italic
* and any width-affecting affordance were therefore impossible, and the
* overlay had to be disabled outright on mobile, where wrapped text drifted
* anyway.
*
* CodeMirror owns the text and the caret together, so there is no second layer
* to keep aligned. The document remains a plain string `getValue()` is
* exactly what gets sent so nothing downstream has to serialize a rich
* document model back into a prompt.
*
* The component is a controlled primitive: it renders `value`, reports edits,
* and exposes an imperative handle for the caret-level operations the composer
* performs (insert a mention, restore a draft, replace a token). Every policy
* decision what a key means, which picker opens, when to send stays with
* the caller.
*/
import React from 'react';
import { history, historyKeymap, standardKeymap } from '@codemirror/commands';
import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state';
import {
EditorView,
drawSelection,
keymap,
placeholder as placeholderExtension,
type KeyBinding,
} from '@codemirror/view';
import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
export interface ComposerSelection {
start: number;
end: number;
}
export interface ComposerChange {
value: string;
selection: ComposerSelection;
/** True when the edit came from a paste rather than typing. */
fromPaste: boolean;
/** The text this edit inserted, empty for deletions. */
insertedText: string;
}
export interface ComposerEditorHandle {
focus(options?: { preventScroll?: boolean }): void;
blur(): void;
isFocused(): boolean;
getValue(): string;
getSelection(): ComposerSelection;
setSelection(start: number, end?: number): void;
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
getScrollDOM(): HTMLElement | null;
}
export interface ComposerEditorProps {
value: string;
onChange: (change: ComposerChange) => void;
/** Caret or selection moved without the document changing. */
onSelectionChange?: (selection: ComposerSelection) => void;
/**
* Key press before CodeMirror handles it. Return true to consume the
* event this is where the composer routes autocomplete navigation,
* message history and send.
*/
onKeyDown?: (event: KeyboardEvent) => boolean;
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (event: ClipboardEvent) => void;
languageContext: ComposerLanguageContext;
placeholder?: string;
editable?: boolean;
spellCheck?: boolean;
/** Mobile keyboards; ignored on desktop. */
autoCorrect?: boolean;
autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
/** Lines of text shown before the editor starts scrolling. */
maxLines?: number;
/**
* Selector of the ancestor the composer must never outgrow. The cap is
* measured the ancestor's height minus the chrome around the editor,
* both read from the DOM and the smaller of it and `maxLines` wins.
*/
boundSelector?: string;
/** Breathing room kept between the grown composer and the bound's edge. */
boundGapPx?: number;
className?: string;
contentClassName?: string;
/**
* Keeps the underlying view alive across unmounts. Supply one from a parent
* that outlives the swap; without it the view is built and destroyed with
* the component, which is correct but expensive on an interaction path.
*/
viewStore?: ComposerEditorViewStore;
'aria-label'?: string;
'data-testid'?: string;
}
/**
* The text inserted by a transaction, used to tell a typed `@` from a pasted
* one. CodeMirror reports the change set directly, so this needs none of the
* prefix/suffix diffing a textarea's `onChange` required.
*/
function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: number, toA: number, fromB: number, toB: number, inserted: { toString(): string }) => void) => void } }): string {
let inserted = '';
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, text) => {
inserted += text.toString();
});
return inserted;
}
/**
* Compartments are configuration keys, not per-view state, so one set can serve
* every editor. They live at module scope because a kept-alive view outlives
* the component that created it: per-instance compartments would be unknown to
* the reused view's configuration, and reconfiguring it would throw.
*/
const editableCompartment = new Compartment();
const placeholderCompartment = new Compartment();
export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEditorProps>(
function ComposerEditor(props, ref) {
const {
value,
languageContext,
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
boundSelector,
boundGapPx = 0,
className,
contentClassName,
} = props;
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
// which would drop focus mid-typing. When a view store is supplied the
// ref lives there, so a kept-alive view keeps calling into whichever
// component instance is currently mounted rather than a dead one.
const localHandlersRef = React.useRef(props);
const store = props.viewStore ?? null;
if (store && !store.handlers) store.handlers = { current: props };
const handlersRef = store?.handlers ?? localHandlersRef;
handlersRef.current = props;
// A layout effect, not a passive one: the mobile composer expands with
// `flushSync` and focuses the editor on the next line, still inside the
// tap's call stack, because that is the only way a mobile browser
// raises the keyboard. flushSync commits layout effects but makes no
// promise about passive ones, so creating the view there would leave
// nothing to focus — the keyboard would rise later, from some other
// path, and the composer would appear to transform only once it moved.
React.useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// A kept view is re-attached rather than rebuilt. Its extensions
// already read through the shared handlers ref, so it needs nothing
// from this instance beyond a parent to live in; the effects below
// re-apply editable, placeholder, value and language context.
const keptView = store?.view;
if (keptView) {
host.appendChild(keptView.dom);
// Measurements taken while detached are meaningless; the view
// re-reads its geometry now that it is back in the document.
keptView.requestMeasure();
viewRef.current = keptView;
return () => {
keptView.dom.remove();
viewRef.current = null;
};
}
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
}];
const view = new EditorView({
state: EditorState.create({
doc: handlersRef.current.value,
extensions: [
history(),
// `drawSelection()` must stay even though the native
// selection is what actually shows (see the theme's
// comment on `composerNativeSelectionExtension`):
// removing it makes CodeMirror enforce cursor
// association on the native selection, which iOS
// answers with severe input lag.
drawSelection(),
composerNativeSelectionExtension,
EditorView.lineWrapping,
// Highest precedence: the composer's own keys must win
// over CodeMirror's defaults (Enter sends, ArrowUp
// walks history, Escape closes a picker).
Prec.highest(keymap.of(interceptKeys)),
keymap.of([...standardKeymap, ...historyKeymap]),
composerLanguage(handlersRef.current.languageContext),
editableCompartment.of(
EditorView.editable.of(handlersRef.current.editable ?? true),
),
placeholderCompartment.of(
placeholderExtension(handlersRef.current.placeholder ?? ''),
),
composerEditorTheme,
EditorView.updateListener.of((update) => {
const handlers = handlersRef.current;
const selection = readSelection(update.state);
if (update.docChanged) {
const fromPaste = update.transactions.some(
(transaction) => transaction.isUserEvent('input.paste'),
);
let insertedText = '';
for (const transaction of update.transactions) {
insertedText += insertedTextOf(transaction);
}
handlers.onChange({
value: update.state.doc.toString(),
selection,
fromPaste,
insertedText,
});
return;
}
if (update.selectionSet) {
handlers.onSelectionChange?.(selection);
}
}),
EditorView.domEventHandlers({
focus: () => { handlersRef.current.onFocus?.(); return false; },
blur: () => { handlersRef.current.onBlur?.(); return false; },
paste: (event) => { handlersRef.current.onPaste?.(event); return false; },
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
: {}),
}),
] satisfies Extension[],
}),
parent: host,
});
viewRef.current = view;
if (store) store.view = view;
return () => {
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its
// lifetime now, and whoever owns the store ends it.
if (store) {
view.dom.remove();
return;
}
view.destroy();
};
// Created once: every changing input is applied through a
// dispatch below rather than by rebuilding the view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Controlled value: only write back when the prop and the document
// genuinely differ, otherwise every keystroke would round-trip and
// reset the caret.
React.useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
// grow-with-content effect applies the scroller's max-height cap
// through a ResizeObserver a frame later — at scroll time the
// overflow does not exist yet, so the scroller stays at the top.
// The caret is at the end here, so once the layout has settled
// (two frames: one for the cap, one after it) pin the scroller to
// the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (viewRef.current !== view) return;
view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
});
});
}, [value]);
React.useEffect(() => {
viewRef.current?.dispatch({ effects: setLanguageContext.of(languageContext) });
}, [languageContext]);
React.useEffect(() => {
viewRef.current?.dispatch({
effects: editableCompartment.reconfigure(EditorView.editable.of(editable)),
});
}, [editable]);
React.useEffect(() => {
viewRef.current?.dispatch({
effects: placeholderCompartment.reconfigure(placeholderExtension(placeholder ?? '')),
});
}, [placeholder]);
// Grow with the content up to `maxLines`, then scroll. The limit is
// measured from the rendered line height rather than assumed, so it
// tracks the composer's responsive typography.
React.useEffect(() => {
const view = viewRef.current;
const host = hostRef.current;
if (!view || !host) return;
// Filling the container means there is no line limit — and the
// limit from the collapsed composer has to be released, or the
// expanded editor keeps scrolling inside an invisible eight-line
// window while the rest of the surface sits empty.
if (fillContainer) {
view.scrollDOM.style.maxHeight = '';
return;
}
const boundEl = boundSelector ? host.closest(boundSelector) : null;
// The bound's direct child our editor lives in: its height minus
// the scroller's is exactly the chrome around the editor — form
// paddings, model row, footer, attachment chips — measured live,
// so the cap needs no estimate of what surrounds the editor.
let branch: HTMLElement | null = null;
if (boundEl) {
branch = host;
while (branch.parentElement && branch.parentElement !== boundEl) {
branch = branch.parentElement;
}
}
const applyLimit = () => {
const lineHeight = parseFloat(
getComputedStyle(view.contentDOM).lineHeight || '',
);
if (!Number.isFinite(lineHeight) || lineHeight <= 0) return;
let cap = lineHeight * maxLines;
if (boundEl && branch) {
const chrome = branch.offsetHeight - view.scrollDOM.offsetHeight;
const available = boundEl.clientHeight - chrome - boundGapPx;
if (available > 0) cap = Math.min(cap, available);
}
const next = `${cap}px`;
// The scroller growing re-fires the observer with an unchanged
// result; writing only on change keeps that loop silent.
if (view.scrollDOM.style.maxHeight !== next) {
view.scrollDOM.style.maxHeight = next;
}
};
applyLimit();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(applyLimit);
observer.observe(host);
if (branch) observer.observe(branch);
if (boundEl) observer.observe(boundEl);
return () => observer.disconnect();
}, [boundGapPx, boundSelector, fillContainer, maxLines]);
React.useEffect(() => {
const view = viewRef.current;
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]);
/**
* The composer box is bigger than its text: it carries padding, and in
* focus mode it fills the surface. Clicking that empty space has always
* put the caret in the text with a textarea the element itself filled
* the box, so the browser did it. CodeMirror's content element does not
* extend into the padding, so the click has to be forwarded.
*/
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
const view = viewRef.current;
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
// A click that already landed in the text needs no help, and
// forwarding it would break drag-selection.
if (view.contentDOM.contains(event.target as Node)) return;
event.preventDefault();
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
?? view.state.doc.length;
view.dispatch({ selection: { anchor: position } });
view.focus();
}, []);
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
focus(options) {
const view = viewRef.current;
if (!view) return;
// preventScroll matters on mobile, where the browser's own
// scroll-into-view fights the keyboard choreography.
view.contentDOM.focus({ preventScroll: options?.preventScroll });
},
blur() {
viewRef.current?.contentDOM.blur();
},
isFocused() {
return viewRef.current?.hasFocus ?? false;
},
getValue() {
return viewRef.current?.state.doc.toString() ?? '';
},
getSelection() {
const view = viewRef.current;
return view ? readSelection(view.state) : { start: 0, end: 0 };
},
setSelection(start, end = start) {
const view = viewRef.current;
if (!view) return;
const max = view.state.doc.length;
view.dispatch({
selection: {
anchor: Math.min(Math.max(start, 0), max),
head: Math.min(Math.max(end, 0), max),
},
});
},
selectAll() {
const view = viewRef.current;
if (!view) return;
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
insertText(text) {
const view = viewRef.current;
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
userEvent: 'input.type',
});
},
caretCoords(position) {
const view = viewRef.current;
if (!view) return null;
const pos = position ?? view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
return coords
? { top: coords.top, bottom: coords.bottom, left: coords.left }
: null;
},
getScrollDOM() {
return viewRef.current?.scrollDOM ?? null;
},
}), []);
return (
<div
ref={hostRef}
data-testid={props['data-testid']}
onMouseDown={handleHostMouseDown}
className={cn(
'composer-editor w-full',
// The editor fills the host so its content box can cover
// the whole clickable area rather than just the text.
'[&_.cm-editor]:h-full',
fillContainer && 'flex min-h-0 flex-1 flex-col',
className,
contentClassName,
)}
/>
);
},
);
function readSelection(state: EditorState): ComposerSelection {
const range = state.selection.main;
return { start: range.from, end: range.to };
}
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import type { ComposerLanguageContext } from '../../language/tokenize';
import { composerLanguage, setLanguageContext } from '../composerLanguage';
const context = (overrides: Partial<ComposerLanguageContext> = {}): ComposerLanguageContext => ({
inputMode: 'normal',
knownAgentNames: new Set(['build']),
confirmedMentions: new Set(),
knownSlashNames: new Set(['review']),
knownSnippetTriggers: new Set(['sig']),
attachmentFilenames: [],
...overrides,
});
const stateWith = (doc: string, ctx = context()) =>
EditorState.create({ doc, extensions: composerLanguage(ctx) });
/** Every decorated stretch as [text, class]. */
const decorations = (state: EditorState) => {
const found: Array<[string, string]> = [];
const set = state.facet(EditorView.decorations)
.map((source) => (typeof source === 'function' ? null : source))
.find(Boolean);
if (!set) return found;
const iterator = set.iter();
while (iterator.value) {
const spec = iterator.value.spec as { class?: string };
found.push([state.doc.sliceString(iterator.from, iterator.to), spec.class ?? '']);
iterator.next();
}
return found;
};
const decoratedText = (state: EditorState) => decorations(state).map(([text]) => text);
describe('composerLanguage — initial decorations', () => {
test('decorates the references it knows about', () => {
expect(decoratedText(stateWith('ask @build to /review'))).toEqual(['@build', '/review']);
});
test('leaves unknown tokens undecorated', () => {
expect(decoratedText(stateWith('ask @stranger to /nothing'))).toEqual([]);
});
test('decorates markdown structure', () => {
expect(decoratedText(stateWith('# Title'))).toEqual(['#', 'Title']);
});
test('plain prose gets no decorations at all', () => {
expect(decoratedText(stateWith('just a sentence'))).toEqual([]);
});
test('an empty document is fine', () => {
expect(decoratedText(stateWith(''))).toEqual([]);
});
test('shell mode disables the language', () => {
expect(decoratedText(stateWith('@build /review', context({ inputMode: 'shell' }))))
.toEqual([]);
});
test('decorated spans carry the shared highlight classes', () => {
const [[, agentClass]] = decorations(stateWith('@build'));
expect(agentClass).toContain('status-success');
});
});
describe('composerLanguage — updates', () => {
test('editing the document retokenizes', () => {
const state = stateWith('hello');
const next = state.update({
changes: { from: 5, insert: ' @build' },
}).state;
expect(decoratedText(next)).toEqual(['@build']);
});
test('deleting a reference removes its decoration', () => {
const state = stateWith('@build hi');
const next = state.update({ changes: { from: 0, to: 7 } }).state;
expect(decoratedText(next)).toEqual([]);
});
test('a new registry repaints without touching the document', () => {
const state = stateWith('ask @deploy');
expect(decoratedText(state)).toEqual([]);
const next = state.update({
effects: setLanguageContext.of(context({ knownAgentNames: new Set(['deploy']) })),
}).state;
expect(decoratedText(next)).toEqual(['@deploy']);
expect(next.doc.toString()).toBe('ask @deploy');
});
test('a transaction that changes neither keeps the same decoration set', () => {
const state = stateWith('@build');
const next = state.update({ selection: { anchor: 0 } }).state;
expect(decoratedText(next)).toEqual(['@build']);
});
test('the document stays the plain string that gets sent', () => {
const state = stateWith('# Title\n@build /review #sig');
expect(state.doc.toString()).toBe('# Title\n@build /review #sig');
});
});
@@ -0,0 +1,189 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import {
COMPOSER_EDITOR_THEME_SPEC,
NATIVE_SELECTION_THEME_SPEC,
composerEditorTheme,
composerNativeSelectionExtension,
} from '../theme';
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
describe('composerEditorTheme', () => {
/**
* EditorView.theme compiles its selectors when this module is imported and
* throws RangeError on a scope it was not given `&light` and `&dark`
* among them, despite both appearing throughout CodeMirror's own base
* theme. A build and a type-check both pass happily on that mistake; it
* surfaces only in the running app, where it takes the composer down.
*/
test('its selectors compile and the theme can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerEditorTheme] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
});
/**
* The composer runs `drawSelection()`, which hides the native caret with
* `caret-color: transparent !important` and draws a `.cm-cursor` element
* instead. Styling `caret-color` looks correct and does nothing, leaving
* CodeMirror's hard-coded black cursor on dark themes.
*/
test('the caret is coloured where it is drawn, not on the native caret', () => {
expect(selectors.some((selector) => selector.includes('.cm-cursor'))).toBe(true);
expect(declarations.includes('caretColor')).toBe(false);
});
test('the drawn caret follows the theme rather than a fixed colour', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
});
/**
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
* latter. `&.cm-editor` matches it.
*/
test('the caret rule is specific enough to beat the base theme', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
expect(cursorRule!.startsWith('&.cm-editor')).toBe(true);
});
/**
* Same trap as the caret, one layer over: `drawSelection()` paints its own
* selection and CodeMirror styles the focused case through a six-class
* selector. A shorter rule silently loses and the selection renders in
* CodeMirror's stock lavender, which buries the token colours.
*/
test('the focused selection is styled at the depth CodeMirror uses', () => {
const focusedRule = selectors.find((selector) =>
selector.includes('.cm-focused') && selector.includes('.cm-selectionBackground'));
expect(focusedRule).toBeDefined();
expect(focusedRule!.includes('.cm-scroller')).toBe(true);
expect(focusedRule!.includes('.cm-selectionLayer')).toBe(true);
});
/**
* An unknown custom property makes the whole declaration invalid rather
* than falling back to something visible, so a misspelled token reads as
* "this element was never styled". `color` inherits, which is how a
* camelCased `--surface-mutedForeground` left the placeholder at full text
* brightness while looking perfectly correct in the source.
*/
test('every theme token is kebab-case, as the theme emits them', () => {
const tokens = [...declarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
expect(tokens.length > 0).toBe(true);
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
test('the selection is translucent so token colours survive it', () => {
const rules = selectors
.filter((selector) => selector.includes('.cm-selectionBackground'))
.map((selector) =>
(COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[selector]);
expect(rules.length > 0).toBe(true);
for (const rule of rules) {
expect(rule.background.includes('transparent')).toBe(true);
}
});
});
describe('composerNativeSelectionTheme', () => {
const nativeSelectors = Object.keys(NATIVE_SELECTION_THEME_SPEC);
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
/**
* Every device layers this over `drawSelection()`: the native selection
* paints over token backgrounds (the painted layer is hidden behind them)
* and iOS attaches its selection handles to it. `drawSelection()` must
* NOT be removed for that: without it CodeMirror starts enforcing cursor
* association on the native selection while typing in wrapped text, and
* iOS answers those programmatic selection moves with severe input lag.
*/
test('it compiles and can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerNativeSelectionExtension] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
});
/**
* `drawSelection()` hides the native selection through a `Prec.highest`
* theme with `!important` on `.cm-line ::selection`. Winning that back
* needs both `!important` and strictly more specificity, because the
* mount order of two highest-precedence themes is not something to bet
* on.
*/
test('the native selection is re-shown with enough weight to win', () => {
const rule = nativeSelectors.find((selector) => selector.includes('::selection'));
expect(rule).toBeDefined();
expect(rule!.includes('.cm-content')).toBe(true);
expect(rule!.includes('.cm-line')).toBe(true);
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.backgroundColor.includes('!important')).toBe(true);
expect(value.backgroundColor.includes('transparent')).toBe(true);
});
test('the painted selection layer is hidden so highlights do not stack', () => {
const rule = nativeSelectors.find((selector) => selector.includes('.cm-selectionLayer'));
expect(rule).toBeDefined();
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.display).toBe('none');
});
/**
* iOS colours its selection drag handles from the caret colour. With
* `drawSelection()`'s `caret-color: transparent !important` in effect the
* handles are drawn invisibly. The native caret must come back with
* enough weight to win, and the drawn cursor layer must go so there are
* not two carets.
*
* BUT a visible native caret makes WebKit re-render its caret UI after
* every keystroke's decoration redraw severe input lag. Both rules are
* therefore scoped to `.oc-native-range`, which only exists while a range
* is selected (when there is no caret to lag on).
*/
test('the native caret is re-enabled, since the handles take its colour', () => {
const rule = nativeSelectors.find((selector) =>
selector.includes('.cm-content')
&& (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector].caretColor);
expect(rule).toBeDefined();
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.caretColor.startsWith('var(--')).toBe(true);
expect(value.caretColor.includes('!important')).toBe(true);
expect(rule!.includes('&.cm-editor')).toBe(true);
});
test('the native caret shows only while a range is selected', () => {
for (const selector of nativeSelectors) {
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector];
if (value.caretColor) {
expect(selector.includes('.oc-native-range')).toBe(true);
}
}
});
test('the drawn cursor layer is hidden so there are not two carets', () => {
const rule = nativeSelectors.find((selector) => selector.includes('.cm-cursorLayer'));
expect(rule).toBeDefined();
expect(rule!.includes('.oc-native-range')).toBe(true);
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.display).toBe('none');
});
test('every theme token is kebab-case, as the theme emits them', () => {
const tokens = [...nativeDeclarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
expect(tokens.length > 0).toBe(true);
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
});
@@ -0,0 +1,97 @@
/**
* The composer's prompt language as a CodeMirror extension.
*
* `tokenizeComposer` already answers "what does this text mean"; this module
* is the thin adapter that turns its ranges into mark decorations and keeps
* them in sync with the document and with the workspace registries.
*
* Why this replaces the mirror overlay: a transparent textarea painted over a
* mirror div can only use styles that do not change glyph advance width, or
* the two layers drift apart and the caret lands in the wrong place. That is
* why bold and italic were never highlighted, and why the overlay had to be
* switched off entirely on mobile. CodeMirror owns the caret and the text, so
* there is no second layer to keep aligned and no metric restriction.
*/
import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
import { resolveHighlightSegments, DEFAULT_HIGHLIGHT_CLASS } from '../../composerHighlight';
import { tokenizeComposer, type ComposerLanguageContext } from '../language/tokenize';
/**
* Replace the workspace knowledge the tokenizer resolves against. Dispatched
* when the agent, command, skill, snippet or attachment registries change
* not on every keystroke, which only changes the document.
*/
export const setLanguageContext = StateEffect.define<ComposerLanguageContext>();
/**
* The context lives in editor state rather than in a closure so the decoration
* field can recompute from `(document, context)` alone, and so a context change
* repaints without remounting the view.
*/
const languageContextField = StateField.define<ComposerLanguageContext>({
create: () => EMPTY_CONTEXT,
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setLanguageContext)) return effect.value;
}
return value;
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
knownSlashNames: new Set(),
knownSnippetTriggers: new Set(),
attachmentFilenames: [],
};
/**
* Decorations for the whole document. The composer holds a prompt, not a
* source file: it is short enough that a full retokenize per change is
* cheaper and far simpler than incremental mapping, and it keeps the editor
* and the send path reading the exact same grammar.
*/
function buildDecorations(text: string, context: ComposerLanguageContext): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const segment of resolveHighlightSegments(text, tokenizeComposer(text, context))) {
// Unstyled stretches need no decoration — the editor's own base text
// color already renders them.
if (segment.className === DEFAULT_HIGHLIGHT_CLASS) continue;
builder.add(segment.start, segment.end, Decoration.mark({ class: segment.className }));
}
return builder.finish();
}
const decorationField = StateField.define<DecorationSet>({
create: (state) => buildDecorations(state.doc.toString(), state.field(languageContextField)),
update(value, transaction) {
const contextChanged = transaction.effects.some((effect) => effect.is(setLanguageContext));
if (!transaction.docChanged && !contextChanged) return value;
return buildDecorations(
transaction.state.doc.toString(),
transaction.state.field(languageContextField),
);
},
provide: (field) => EditorView.decorations.from(field),
});
/**
* The composer language extension. Install once; feed it registry updates with
* `setLanguageContext`.
*/
export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEXT) {
return [
languageContextField.init(() => initial),
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -0,0 +1,162 @@
/**
* The composer editor's layout, typography and caret.
*
* Token colours are not here: they come from the shared highlight classes the
* language layer emits, so the composer and the message list stay in step.
*/
import { EditorView } from '@codemirror/view';
/**
* Exported for the regression test, which asserts the caret is styled where it
* is actually drawn.
*/
export const COMPOSER_EDITOR_THEME_SPEC = {
'&': {
backgroundColor: 'transparent',
color: 'var(--surface-foreground)',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': {
padding: '0',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
// The content box must cover the whole editor, not just the text, so
// clicking the empty space below the last line still lands in it.
minHeight: '100%',
},
// The caret is NOT the native one. `drawSelection()` hides that with
// `caret-color: transparent !important` at the highest precedence and
// draws its own `.cm-cursor` element, whose base style is a hard-coded
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
// does nothing at all — the border is what has to be coloured.
//
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
// which needs the theme to declare itself dark. OpenChamber themes are not
// only light or dark, so the cursor takes the surface foreground directly
// instead. `&.cm-editor` matches the specificity of that `&dark` rule, and
// theme styles mount after the base theme, so this wins in every variant.
//
// The `&light` / `&dark` scopes are NOT usable here: EditorView.theme
// builds its selectors without scopes and throws RangeError on them the
// moment this module is imported.
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
borderLeftColor: 'var(--surface-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
overflowX: 'hidden',
},
// Kebab-case: the theme emits `--surface-muted-foreground`. A camelCased
// name here is not a missing colour but an invalid declaration, and since
// `color` inherits, the placeholder silently renders at full text
// brightness instead.
'.cm-placeholder': { color: 'var(--surface-muted-foreground)' },
// `drawSelection()` paints its own selection layer, and CodeMirror styles
// it for the focused editor through
// `&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground`
// — six classes deep, so anything shorter loses and the selection comes out
// in CodeMirror's stock lavender. Both rules below match the shape of the
// ones they replace: unfocused first, then the focused case.
//
// The tint is translucent on purpose. An opaque selection would bury the
// token colours the composer exists to show; the point of selecting text
// here is to move it, not to stop reading it.
'&.cm-editor .cm-selectionBackground, & .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 45%, transparent)',
},
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
},
// The native selection still shows through in places CodeMirror does not
// draw over, such as the placeholder. Same colour as the native-selection
// theme below, for the same reason: the selection token carries its own
// alpha and reads as nearly invisible when mixed down again.
'& ::selection': {
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
},
};
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
/**
* Every device keeps `drawSelection()` but shows the NATIVE selection through
* it, for two independent reasons:
*
* - iOS attaches its selection handles (the draggable pins after a
* double-tap) to the *visible* native selection, and `drawSelection()`
* hides it with `.cm-line ::selection { background: transparent
* !important }`, so the handles never appear and range selection is
* undiscoverable.
* - The painted selection layer sits *behind* the content, so any token with
* its own background inline code, code fences covers it completely and
* the selection is invisible inside those spans. The native selection
* paints over element backgrounds.
*
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
* association on the native selection while typing in wrapped text
* programmatic selection moves that iOS answers with severe input lag (each
* one also resets the keyboard's autocorrect context). Typing must stay on
* the drawn-selection code path; only the paint changes.
*
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
* they carry `!important` and one class more specificity
* (`.cm-content .cm-line` vs its `.cm-line`) to win regardless of style
* mount order. The painted selection layer is hidden rather than removed
* two highlights would otherwise stack.
*/
export const NATIVE_SELECTION_THEME_SPEC = {
// Built from `--primary`, not `--interactive-selection`: themes define the
// selection token with its own alpha (often under 10%), so mixing it with
// transparent again leaves the highlight barely perceptible. `--primary`
// is a full-strength colour in every theme; a low mix of it reads as a
// classic editor selection while the token colours stay legible through it.
'& .cm-content .cm-line ::selection, & .cm-content .cm-line::selection': {
backgroundColor:
'color-mix(in srgb, var(--primary) 25%, transparent) !important',
},
// iOS derives the colour of its selection UI — the drag handles included —
// from the caret colour, and `drawSelection()` sets `caret-color:
// transparent !important` on both `.cm-content` and `.cm-line`. A visible
// native selection alone is therefore not enough: the handles get drawn,
// in transparent.
//
// But a visible native caret is not free either: while it shows, WebKit
// re-renders its caret UI after every keystroke's decoration redraw, which
// arrives as severe input lag. The handles only exist while a RANGE is
// selected — exactly when there is no caret — so the native caret (and the
// drawn cursor layer's absence) are scoped to `.oc-native-range`, which
// `composerNativeSelectionExtension` sets on the editor whenever the main
// selection is non-empty. Typing stays on the transparent-native-caret
// fast path.
'&.cm-editor.oc-native-range .cm-content, &.cm-editor.oc-native-range .cm-content .cm-line': {
caretColor: 'var(--surface-foreground) !important',
},
'&.oc-native-range .cm-scroller > .cm-cursorLayer': {
display: 'none',
},
// The layers live beside the content, as children of the scroller.
'& .cm-scroller > .cm-selectionLayer': {
display: 'none',
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
* above plus the `.oc-native-range` marker class that scopes its caret rules
* to the moments a range is actually selected. `editorAttributes`
* re-evaluates on every update, so the class follows the selection with no
* listener of its own.
*/
export const composerNativeSelectionExtension = [
composerNativeSelectionTheme,
EditorView.editorAttributes.of((view) =>
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
];
@@ -0,0 +1,32 @@
/**
* Somewhere to keep a composer editor alive across an unmount.
*
* The mobile composer swaps between a collapsed pill and the full composer,
* which are different subtrees so the editor unmounts and mounts again on
* every keyboard toggle. With a textarea that cost nothing. Building a
* CodeMirror view is not nothing: extensions, state, document, decorations and
* a first measure, all inside the tap's `flushSync`, before the browser is
* allowed to paint the swap. That is enough to push the visible transformation
* past the keyboard animation, which is what it looked like from the outside.
*
* Handing the editor a store owned by something longer-lived lets the view be
* detached and re-attached instead of destroyed and rebuilt. Whoever creates
* the store owns the view's lifetime and must destroy it.
*/
import type { EditorView } from '@codemirror/view';
import type { ComposerEditorProps } from './ComposerEditor';
export interface ComposerEditorViewStore {
view: EditorView | null;
/**
* Where the view's extensions read their callbacks from. It lives here
* rather than in the component so a kept-alive view keeps calling into
* whichever instance is currently mounted, never a dead one.
*/
handlers: { current: ComposerEditorProps } | null;
}
export function createComposerEditorViewStore(): ComposerEditorViewStore {
return { view: null, handlers: null };
}
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'bun:test';
import {
classifyMention,
cleanMentionName,
isMentionBoundary,
looksLikeFilePath,
scanMentions,
} from '../mentions';
const names = (text: string) => scanMentions(text).map((token) => token.name);
const raws = (text: string) => scanMentions(text).map((token) => token.raw);
describe('scanMentions — boundaries', () => {
test('a mention at the start of the text', () => {
expect(names('@build do this')).toEqual(['build']);
});
test('a mention after whitespace', () => {
expect(names('ask @build about it')).toEqual(['build']);
});
test('an email address is not a mention', () => {
expect(names('write to me@example.com')).toEqual([]);
});
test('a scoped package is not a mention', () => {
expect(names('install @scope/pkg')).toEqual(['scope/pkg']);
expect(names('bump foo@scope/pkg')).toEqual([]);
});
test('opening punctuation still starts a mention', () => {
expect(names('(@build) [@plan] {@x.ts} "@y.ts"')).toEqual(['build', 'plan', 'x.ts', 'y.ts']);
});
test('multiple mentions on one line', () => {
expect(names('@a.ts and @b.ts')).toEqual(['a.ts', 'b.ts']);
});
test('mentions across lines', () => {
expect(names('@a.ts\n@b.ts')).toEqual(['a.ts', 'b.ts']);
});
test('a bare @ is not a mention', () => {
expect(names('call me @ noon')).toEqual([]);
});
test('a token that cleans away to nothing is skipped', () => {
expect(names('@... and @`')).toEqual([]);
});
test('text without @ scans to nothing', () => {
expect(scanMentions('plain text')).toEqual([]);
expect(scanMentions('')).toEqual([]);
});
test('isMentionBoundary agrees with the scanner', () => {
expect(isMentionBoundary('@a', 0)).toBe(true);
expect(isMentionBoundary('x @a', 2)).toBe(true);
expect(isMentionBoundary('x@a', 1)).toBe(false);
expect(isMentionBoundary('1@a', 1)).toBe(false);
});
});
describe('scanMentions — name cleanup', () => {
test('trailing sentence punctuation is not part of the name', () => {
expect(names('see @a/b.ts, then @c/d.ts.')).toEqual(['a/b.ts', 'c/d.ts']);
expect(names('@x.ts! @y.ts? @z.ts;')).toEqual(['x.ts', 'y.ts', 'z.ts']);
});
test('wrapping quotes and brackets are stripped from both ends', () => {
expect(names('`@a/b.ts`')).toEqual(['a/b.ts']);
expect(names('(@a/b.ts)')).toEqual(['a/b.ts']);
expect(names('<@a/b.ts>')).toEqual(['a/b.ts']);
});
test('the raw token still covers the punctuation the name dropped', () => {
expect(raws('see @a/b.ts, ok')).toEqual(['@a/b.ts,']);
});
test('the reference span excludes brushing punctuation', () => {
const text = 'see @a/b.ts, ok';
const [token] = scanMentions(text);
expect(text.slice(token.start, token.end)).toBe('@a/b.ts');
});
test('leading noise shifts the reference span past it', () => {
const text = '`@a/b.ts`';
const [token] = scanMentions(text);
expect(text.slice(token.start, token.end)).toBe('@a/b.ts');
});
test('a trailing slash is kept — directories are mentionable', () => {
expect(names('@src/components/')).toEqual(['src/components/']);
});
test('cleanMentionName is idempotent', () => {
expect(cleanMentionName(cleanMentionName('`a/b.ts`,'))).toBe('a/b.ts');
});
});
describe('scanMentions — offsets', () => {
test('a clean token has identical reference and raw spans', () => {
const text = 'ask @build now';
const [token] = scanMentions(text);
expect(text.slice(token.start, token.end)).toBe(token.raw);
expect(token.start).toBe(4);
expect(token.end).toBe(10);
});
});
describe('classifyMention', () => {
const classifier = {
knownAgentNames: new Set(['build', 'plan']),
confirmedMentions: new Set(['NOTES']),
};
test('a known agent name classifies as an agent', () => {
expect(classifyMention('build', classifier)).toBe('agent');
});
test('agent matching is case-insensitive', () => {
expect(classifyMention('Build', classifier)).toBe('agent');
});
test('a path-like name classifies as a file', () => {
expect(classifyMention('src/app.ts', classifier)).toBe('file');
expect(classifyMention('README.md', classifier)).toBe('file');
expect(classifyMention('win\\path', classifier)).toBe('file');
});
test('a picker-confirmed extensionless name classifies as a file', () => {
expect(classifyMention('NOTES', classifier)).toBe('file');
});
test('an unknown bare word classifies as nothing', () => {
expect(classifyMention('nothing', classifier)).toBeNull();
expect(classifyMention('', classifier)).toBeNull();
});
test('an agent name wins over a file-looking name', () => {
const shadowed = {
knownAgentNames: new Set(['a.ts']),
confirmedMentions: new Set<string>(),
};
expect(classifyMention('a.ts', shadowed)).toBe('agent');
});
test('looksLikeFilePath is independent of the agent list', () => {
expect(looksLikeFilePath('a/b', new Set())).toBe(true);
expect(looksLikeFilePath('plain', new Set())).toBe(false);
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
});
});
@@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test';
import { pathHighlightRanges, scanPaths } from '../paths';
const paths = (text: string) => scanPaths(text).map((token) => token.path);
describe('scanPaths — what counts as a path', () => {
test('a home-relative path', () => {
expect(paths('see ~/repos/ocb/README.md')).toEqual(['/repos/ocb/README.md']);
});
test('a project-relative path', () => {
expect(paths('open ~src/components/App.tsx')).toEqual(['src/components/App.tsx']);
});
test('a bare filename with an extension', () => {
expect(paths('edit ~README.md')).toEqual(['README.md']);
});
test('a windows path', () => {
expect(paths('at ~C:\\repo\\a.ts')).toEqual(['C:\\repo\\a.ts']);
});
test('a word without a separator or extension is prose', () => {
expect(paths('it took ~approximately an hour')).toEqual([]);
expect(paths('~ish')).toEqual([]);
});
test('an approximate number is prose', () => {
expect(paths('about ~500 items')).toEqual([]);
});
test('an approximate decimal is prose, not a file', () => {
// `~` also reads as "about"; an extension must start with a letter.
expect(paths('roughly ~1.2 seconds')).toEqual([]);
expect(paths('~0.5x slower')).toEqual([]);
});
});
describe('scanPaths — boundaries', () => {
test('a path at the start of the text', () => {
expect(paths('~src/a.ts is the file')).toEqual(['src/a.ts']);
});
test('a tilde inside a word is not a path', () => {
expect(paths('foo~bar/baz.ts')).toEqual([]);
});
test('brackets and quotes still open a path', () => {
expect(paths('(~src/a.ts) "~b/c.ts"')).toEqual(['src/a.ts', 'b/c.ts']);
});
test('several paths on one line', () => {
expect(paths('~a/b.ts and ~c/d.ts')).toEqual(['a/b.ts', 'c/d.ts']);
});
test('trailing sentence punctuation is not part of the path', () => {
expect(paths('see ~src/a.ts, then stop.')).toEqual(['src/a.ts']);
});
});
describe('scanPaths — not confused with other tilde syntax', () => {
test('a tilde code fence is left alone', () => {
expect(paths('~~~\nbody\n~~~')).toEqual([]);
});
test('strikethrough is left alone', () => {
expect(paths('~~struck out~~')).toEqual([]);
});
test('text without a tilde scans to nothing', () => {
expect(scanPaths('plain text')).toEqual([]);
expect(scanPaths('')).toEqual([]);
});
});
describe('pathHighlightRanges', () => {
test('the range covers the tilde and the path', () => {
const text = 'see ~src/a.ts here';
const [range] = pathHighlightRanges(text);
expect(text.slice(range.start, range.end)).toBe('~src/a.ts');
expect(range.style).toBe('path');
});
test('prose produces no ranges', () => {
expect(pathHighlightRanges('nothing here')).toEqual([]);
});
});
@@ -0,0 +1,139 @@
import { describe, expect, test } from 'bun:test';
import {
collectKnownTokenNames,
filterKnownTokens,
scanPrefixTokens,
} from '../prefixTokens';
const slashNames = (text: string) => scanPrefixTokens(text, '/').map((token) => token.name);
const hashNames = (text: string) => scanPrefixTokens(text, '#').map((token) => token.name);
describe('scanPrefixTokens — boundaries', () => {
test('a token at the start of the text', () => {
expect(slashNames('/review this')).toEqual(['review']);
expect(hashNames('#note here')).toEqual(['note']);
});
test('a token after whitespace, mid-sentence', () => {
expect(slashNames('please run /explore now')).toEqual(['explore']);
expect(hashNames('use #sig at the end')).toEqual(['sig']);
});
test('a token after a newline', () => {
expect(slashNames('line one\n/review')).toEqual(['review']);
});
test('a path segment is not a slash token', () => {
expect(slashNames('src/components/App.tsx')).toEqual([]);
expect(slashNames('see a/b')).toEqual([]);
});
test('a fragment or issue reference is not a snippet token', () => {
expect(hashNames('issue#42')).toEqual([]);
expect(hashNames('page.html#anchor')).toEqual([]);
});
test('multiple tokens on one line', () => {
expect(slashNames('/plan then /review')).toEqual(['plan', 'review']);
});
test('a name must start with an alphanumeric', () => {
expect(slashNames('/-dash')).toEqual([]);
expect(slashNames('/_under')).toEqual([]);
expect(hashNames('#-x')).toEqual([]);
});
test('names may contain dashes, underscores and digits', () => {
expect(slashNames('/workspace-review /My_Skill /a1')).toEqual([
'workspace-review',
'My_Skill',
'a1',
]);
});
test('a bare sigil is not a token', () => {
expect(slashNames('a / b')).toEqual([]);
expect(hashNames('a # b')).toEqual([]);
});
test('text without the sigil scans to nothing', () => {
expect(scanPrefixTokens('nothing here', '/')).toEqual([]);
expect(scanPrefixTokens('', '#')).toEqual([]);
});
test('the two sigils do not see each other', () => {
expect(slashNames('#snippet')).toEqual([]);
expect(hashNames('/skill')).toEqual([]);
});
});
describe('scanPrefixTokens — offsets', () => {
test('start and end delimit the sigil plus the name', () => {
const text = 'run /review now';
const [token] = scanPrefixTokens(text, '/');
expect(text.slice(token.start, token.end)).toBe('/review');
expect(token.prefix).toBe('/');
});
test('the boundary whitespace is not part of the token', () => {
const [token] = scanPrefixTokens(' /plan', '/');
expect(token.start).toBe(2);
});
test('adjacent tokens keep independent offsets', () => {
const text = '/a /b';
const tokens = scanPrefixTokens(text, '/');
expect(tokens.map((token) => text.slice(token.start, token.end))).toEqual(['/a', '/b']);
});
});
describe('filterKnownTokens', () => {
const tokens = scanPrefixTokens('/Review /unknown /plan', '/');
test('keeps only tokens present in the known set', () => {
expect(filterKnownTokens(tokens, new Set(['review', 'plan'])).map((t) => t.name))
.toEqual(['Review', 'plan']);
});
test('case-insensitive is the default comparison', () => {
expect(filterKnownTokens(tokens, new Set(['review'])).map((t) => t.name))
.toEqual(['Review']);
});
test('exact comparison respects the registered casing', () => {
expect(filterKnownTokens(tokens, new Set(['review']), 'exact')).toEqual([]);
expect(filterKnownTokens(tokens, new Set(['Review']), 'exact').map((t) => t.name))
.toEqual(['Review']);
});
test('an empty known set matches nothing', () => {
expect(filterKnownTokens(tokens, new Set())).toEqual([]);
});
});
describe('collectKnownTokenNames', () => {
test('returns distinct names in first-occurrence order', () => {
expect(collectKnownTokenNames(
'/plan and /review and /plan again',
'/',
new Set(['plan', 'review']),
)).toEqual(['plan', 'review']);
});
test('unknown tokens are dropped', () => {
expect(collectKnownTokenNames('/plan /nope', '/', new Set(['plan'])))
.toEqual(['plan']);
});
test('exact comparison is available for registry-cased names', () => {
expect(collectKnownTokenNames('/Deploy', '/', new Set(['Deploy']), 'exact'))
.toEqual(['Deploy']);
expect(collectKnownTokenNames('/deploy', '/', new Set(['Deploy']), 'exact'))
.toEqual([]);
});
test('no tokens yields an empty list', () => {
expect(collectKnownTokenNames('plain text', '/', new Set(['plan']))).toEqual([]);
});
});
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test';
import { buildHighlightParts } from '../../../composerHighlight';
import {
tokenizeComposer,
tokenizeMentions,
type ComposerLanguageContext,
} from '../tokenize';
const context = (overrides: Partial<ComposerLanguageContext> = {}): ComposerLanguageContext => ({
inputMode: 'normal',
knownAgentNames: new Set(['build', 'plan']),
confirmedMentions: new Set(['NOTES']),
knownSlashNames: new Set(['review', 'explore']),
knownSnippetTriggers: new Set(['sig']),
attachmentFilenames: [],
...overrides,
});
/** The substring and style of every range, sorted for stable comparison. */
const styled = (text: string, ctx = context()) =>
tokenizeComposer(text, ctx)
.map((range) => [text.slice(range.start, range.end), range.style] as const)
.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
const stylesOf = (text: string, ctx = context()) =>
new Set(tokenizeComposer(text, ctx).map((range) => range.style));
describe('tokenizeComposer — reference constructs', () => {
test('a known agent mention is styled as an agent', () => {
expect(styled('ask @build please')).toEqual([['@build', 'mentionAgent']]);
});
test('a path mention is styled as a file', () => {
expect(styled('see @src/app.ts')).toEqual([['@src/app.ts', 'mentionFile']]);
});
test('an unknown bare mention is not tokenized', () => {
expect(styled('hi @stranger')).toEqual([]);
});
test('a known slash token is styled as a command', () => {
expect(styled('run /review now')).toEqual([['/review', 'mentionCommand']]);
});
test('an unknown slash token stays plain prose', () => {
expect(styled('run /nosuchthing now')).toEqual([]);
});
test('a known snippet trigger is styled as a snippet', () => {
expect(styled('end with #sig')).toEqual([['#sig', 'mentionSnippet']]);
});
test('an attachment citation is styled as a file', () => {
expect(styled('here [shot.png]', context({ attachmentFilenames: ['shot.png'] })))
.toEqual([['[shot.png]', 'mentionFile']]);
});
test('a citation for a file that is not attached is not tokenized', () => {
expect(styled('here [other.png]', context({ attachmentFilenames: ['shot.png'] })))
.toEqual([]);
});
});
describe('tokenizeComposer — a path is highlighted but never attached', () => {
test('a ~path is styled', () => {
expect(styled('see ~src/app.ts')).toEqual([['~src/app.ts', 'path']]);
});
test('it needs no registry and no confirmation, unlike @', () => {
// The same path behind `@` only resolves because it looks like a path;
// behind `~` it is inert either way.
const empty = context({ knownAgentNames: new Set(), confirmedMentions: new Set() });
expect(styled('~docs/NOTES', empty)).toEqual([['~docs/NOTES', 'path']]);
});
test('an @mention covering the same text keeps its own style', () => {
expect(styled('@src/app.ts')).toEqual([['@src/app.ts', 'mentionFile']]);
});
test('both forms can appear in one message', () => {
expect(stylesOf('attach @a/b.ts but only mention ~c/d.ts'))
.toEqual(new Set(['mentionFile', 'path']));
});
});
describe('tokenizeComposer — emphasis and attention', () => {
test('emphasis is tokenized', () => {
expect(stylesOf('**bold** and *slanted*'))
.toEqual(new Set(['marker', 'strong', 'emphasis']));
});
test('an attention line is tokenized', () => {
expect(stylesOf('!!! read this')).toEqual(new Set(['marker', 'attention']));
});
test('shell mode still disables everything', () => {
expect(tokenizeComposer('**bold** ~a/b.ts !!! x', context({ inputMode: 'shell' })))
.toEqual([]);
});
});
describe('tokenizeComposer — markdown still applies', () => {
test('markdown and references coexist in one pass', () => {
expect(stylesOf('# Title\n- see @src/app.ts and /review'))
.toEqual(new Set(['marker', 'heading', 'listMarker', 'mentionFile', 'mentionCommand']));
});
test('fenced code is tokenized', () => {
expect(stylesOf('```\nplain\n```').has('codeFence')).toBe(true);
});
});
describe('tokenizeComposer — disabled and empty paths', () => {
test('shell mode tokenizes nothing', () => {
expect(tokenizeComposer('@build /review #sig', context({ inputMode: 'shell' })))
.toEqual([]);
});
test('empty text tokenizes nothing', () => {
expect(tokenizeComposer('', context())).toEqual([]);
});
test('empty registries leave their sigils plain', () => {
expect(styled('/review #sig', context({
knownSlashNames: new Set(),
knownSnippetTriggers: new Set(),
}))).toEqual([]);
});
});
describe('tokenizeComposer — ranges are usable', () => {
test('every range indexes real text', () => {
const text = '# H\n@src/a.ts /review #sig `code` [x.png]';
const ctx = context({ attachmentFilenames: ['x.png'] });
for (const range of tokenizeComposer(text, ctx)) {
expect(range.start >= 0).toBe(true);
expect(range.end <= text.length).toBe(true);
expect(range.end > range.start).toBe(true);
}
});
test('the ranges reconstruct the text exactly through buildHighlightParts', () => {
const text = 'ping @build about /review\n> quoted `code`';
const parts = buildHighlightParts(text, tokenizeComposer(text, context()));
expect(parts).not.toBeNull();
expect(parts!.map((part) => part.text).join('')).toBe(text);
});
test('a mention inside inline code keeps the mention style', () => {
// Mentions outrank code in the priority table, so a referenced path
// stays recognizable even when the user wrapped it in backticks.
const text = '`@src/app.ts`';
const parts = buildHighlightParts(text, tokenizeComposer(text, context()));
expect(parts!.map((part) => part.text)).toEqual(['`', '@src/app.ts', '`']);
});
});
describe('tokenizeMentions', () => {
test('classifies agents and files, skipping unknown words', () => {
expect(tokenizeMentions('@build @src/a.ts @nobody', {
knownAgentNames: new Set(['build']),
confirmedMentions: new Set(),
})).toEqual([
{ start: 0, end: 6, kind: 'agent' },
{ start: 7, end: 16, kind: 'file' },
]);
});
test('a picker-confirmed extensionless name counts as a file', () => {
expect(tokenizeMentions('@NOTES', {
knownAgentNames: new Set(),
confirmedMentions: new Set(['NOTES']),
})).toEqual([{ start: 0, end: 6, kind: 'file' }]);
});
});
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import { resolveAutocompleteTrigger, type TriggerContext } from '../triggers';
const normal: TriggerContext = { inputMode: 'normal' };
/** Resolve with the caret placed at the `|` marker in `text`. */
const at = (text: string, context: TriggerContext = normal) => {
const cursor = text.indexOf('|');
if (cursor === -1) throw new Error('caret marker `|` missing');
return resolveAutocompleteTrigger(text.replace('|', ''), cursor, context);
};
describe('command palette', () => {
test('a leading slash opens the command palette', () => {
expect(at('/rev|')).toEqual({ kind: 'command', query: 'rev' });
});
test('a bare leading slash opens it with an empty query', () => {
expect(at('/|')).toEqual({ kind: 'command', query: '' });
});
test('a space anywhere turns it into an invocation, not a search', () => {
expect(at('/review |')?.kind).not.toBe('command');
expect(at('/rev|iew now')?.kind).not.toBe('command');
});
test('the caret must stay inside the command word', () => {
expect(at('/review\nnext line|')?.kind).not.toBe('command');
});
test('a slash that is not in the first column is not the palette', () => {
expect(at(' /rev|')).toEqual({ kind: 'skill', query: 'rev' });
});
});
describe('inline skill picker', () => {
test('a slash after whitespace opens the skill picker', () => {
expect(at('please run /explo|')).toEqual({ kind: 'skill', query: 'explo' });
});
test('a slash after a newline opens it', () => {
expect(at('line\n/pl|')).toEqual({ kind: 'skill', query: 'pl' });
});
test('a path separator does not open it', () => {
expect(at('src/comp|')).toBeNull();
});
test('a space after the sigil closes it', () => {
expect(at('run /explore |')).toBeNull();
});
test('the nearest slash before the caret wins', () => {
expect(at('/a b /c|')).toEqual({ kind: 'skill', query: 'c' });
});
});
describe('snippet picker', () => {
test('a hash after whitespace opens the snippet picker', () => {
expect(at('use #sig|')).toEqual({ kind: 'snippet', query: 'sig' });
});
test('a hash at the start of the text opens it', () => {
expect(at('#sig|')).toEqual({ kind: 'snippet', query: 'sig' });
});
test('an issue reference does not open it', () => {
expect(at('issue#42|')).toBeNull();
});
test('a slash outranks a hash when both are candidates', () => {
expect(at('#tag /skill|')).toEqual({ kind: 'skill', query: 'skill' });
});
});
describe('mention picker', () => {
test('an at-sign after whitespace opens the mention picker', () => {
expect(at('see @src/ap|')).toEqual({ kind: 'mention', query: 'src/ap' });
});
test('a bare at-sign opens it with an empty query', () => {
expect(at('@|')).toEqual({ kind: 'mention', query: '' });
});
test('an email address does not open it', () => {
expect(at('me@example|')).toBeNull();
});
test('a space after the sigil closes it', () => {
expect(at('@build now|')).toBeNull();
});
test('a pasted at-sign does not open the picker', () => {
expect(at('@src/app.ts|', {
inputMode: 'normal',
inputSource: 'paste',
insertedText: '@src/app.ts',
})).toBeNull();
});
test('a paste without an at-sign still resolves normally', () => {
expect(at('@src|', {
inputMode: 'normal',
inputSource: 'paste',
insertedText: 'src',
})).toEqual({ kind: 'mention', query: 'src' });
});
});
describe('precedence and disabling', () => {
test('shell mode disables every picker', () => {
const shell: TriggerContext = { inputMode: 'shell' };
expect(at('/rev|', shell)).toBeNull();
expect(at('@src|', shell)).toBeNull();
expect(at('#sig|', shell)).toBeNull();
});
test('the command palette outranks the inline skill picker', () => {
expect(at('/pl|')).toEqual({ kind: 'command', query: 'pl' });
});
test('plain prose triggers nothing', () => {
expect(at('just typing a sentence|')).toBeNull();
expect(at('|')).toBeNull();
});
});
@@ -0,0 +1,132 @@
/**
* The composer's `@mention` grammar the single source of truth for what an
* `@token` means.
*
* Before this module the same rule was re-implemented four times inside
* ChatInput.tsx with subtly different cleanup (highlighting, send-time
* extraction, deletion, and the autocomplete trigger). Each new reference
* type had to be taught to all four. Everything
* that needs to know where mentions are now scans with `scanMentions` and
* decides what they are with `classifyMention`.
*
* A mention is `@` at a token boundary followed by non-whitespace. The visible
* span (`start`..`end`) covers the raw token including any punctuation that
* merely brushes against it; `name` is that token cleaned of wrapping
* punctuation, and is what gets matched against agents and file paths.
*/
/**
* Characters that may sit directly before `@`. Anything else (a letter, digit
* or `/`) means the `@` belongs to the preceding word an email address, a
* scoped npm package, a path segment and is not a mention.
*/
const MENTION_BOUNDARY_BEFORE = /[\s()[\]{}<>"'`,.;:]/;
/**
* Punctuation that commonly wraps a mention and is never part of the name.
* The two sets are deliberately symmetric: every bracket accepted before `@`
* is also stripped from the tail, so `[@plan]` and `(@plan)` both reference
* `plan`. The pre-unification rules allowed `[`/`{` in front but only stripped
* `)` behind, which left `@plan]` as the resolved name.
*/
const LEADING_NOISE = /^[`"'<([{]+/;
const TRAILING_NOISE = /[)\]},.;:!?`"'>]+$/;
const MENTION_SCAN = /@([^\s]+)/g;
export interface MentionToken {
/** Offset of the `@`. */
start: number;
/**
* Offset just past the reference itself the `@` plus the cleaned name.
* This is the span to highlight: in `see @a/b.ts, ok` the comma is
* punctuation of the sentence, not part of the file being referenced.
*/
end: number;
/** The raw token including `@` and any brushing punctuation. */
raw: string;
/** The token with `@` and wrapping punctuation removed. */
name: string;
}
/** True when `@` at `index` starts a mention rather than continuing a word. */
export function isMentionBoundary(text: string, index: number): boolean {
if (index <= 0) return true;
return MENTION_BOUNDARY_BEFORE.test(text[index - 1]);
}
/** Strip the punctuation that wraps a mention without belonging to it. */
export function cleanMentionName(rawName: string): string {
return rawName
.trim()
.replace(LEADING_NOISE, '')
.replace(TRAILING_NOISE, '');
}
/**
* Find every `@mention` in `text`. Tokens whose name cleans away to nothing
* (a bare `@`, `@...`) are skipped there is nothing to reference.
*/
export function scanMentions(text: string): MentionToken[] {
if (!text || !text.includes('@')) return [];
const tokens: MentionToken[] = [];
MENTION_SCAN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MENTION_SCAN.exec(text)) !== null) {
const start = match.index;
if (!isMentionBoundary(text, start)) continue;
const rawName = match[1] ?? '';
const name = cleanMentionName(rawName);
if (!name) continue;
// The cleaned name is a substring of the raw one, so its offset inside
// the token is exactly how much leading noise was stripped.
const nameStart = start + 1 + rawName.indexOf(name);
tokens.push({
start,
end: nameStart + name.length,
raw: match[0],
name,
});
}
return tokens;
}
export type MentionKind = 'agent' | 'file';
export interface MentionClassifier {
/** Lowercased names of the agents that can be mentioned. */
knownAgentNames: ReadonlySet<string>;
/** Mention paths confirmed by the picker, a drop, or a restored draft. */
confirmedMentions: ReadonlySet<string>;
}
/**
* A name looks like a file when it carries path structure (a separator or an
* extension) or when the user confirmed it explicitly through the picker.
* Agents win over files: an agent name is an exact, known identifier.
*/
export function classifyMention(
name: string,
classifier: MentionClassifier,
): MentionKind | null {
if (!name) return null;
if (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent';
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
return null;
}
export function looksLikeFilePath(
name: string,
confirmedMentions: ReadonlySet<string>,
): boolean {
return name.includes('/')
|| name.includes('\\')
|| name.includes('.')
|| confirmedMentions.has(name);
}
@@ -0,0 +1,88 @@
/**
* `~path` a path written for the reader, not for the machine.
*
* `@path` attaches a file: it resolves against the project, searches, and rides
* along with the message. Often that is not what is wanted. Explaining where
* something lives, or naming a file in another repository, should not silently
* attach it but it should still stand out from prose, because a path buried
* in a sentence is hard to read.
*
* `~` marks exactly that: **highlighting without attachment**. It is inert by
* design, so nothing here feeds the autocomplete or the send path.
*
* @see mentions.ts for `@`, which does attach.
*/
import type { HighlightRange } from '../../composerHighlight';
/**
* A path token: `~` followed by non-whitespace. The same trailing punctuation
* rule as mentions applies, so `see ~src/app.ts,` marks the path and leaves
* the comma to the sentence.
*/
const PATH_SCAN = /~([^\s~]+)/g;
const TRAILING_NOISE = /[)\]},.;:!?`"'>]+$/;
/**
* `~` opens a path only at a token boundary. Inside a word it is arithmetic,
* an approximation, or part of an identifier.
*/
const BOUNDARY_BEFORE = /[\s()[\]{}<>"'`,;:]/;
export interface PathToken {
/** Offset of the `~`. */
start: number;
/** Offset just past the path. */
end: number;
/** The path without its `~`. */
path: string;
}
/**
* True when the token looks like a path rather than a stray word. A separator
* or a file extension is required, so `~approximately` stays prose while
* `~/repos/ocb` and `~README.md` are paths.
*
* The extension must begin with a letter. `~` also reads as "about" in front
* of a number, and `~1.2 seconds` is far more likely to be prose than a file.
*/
function looksLikePath(value: string): boolean {
return value.includes('/') || value.includes('\\') || /\.[A-Za-z]/.test(value);
}
/**
* Find every `~path` in `text`.
*
* Deliberately blind to `~~strikethrough~~` and to `~~~` code fences: the scan
* stops at `~`, so a doubled marker yields nothing to highlight and the fence
* tokenizer keeps its own text.
*/
export function scanPaths(text: string): PathToken[] {
if (!text || !text.includes('~')) return [];
const tokens: PathToken[] = [];
PATH_SCAN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PATH_SCAN.exec(text)) !== null) {
const start = match.index;
const before = start > 0 ? text[start - 1] : '';
if (before && !BOUNDARY_BEFORE.test(before)) continue;
const path = (match[1] ?? '').replace(TRAILING_NOISE, '');
if (!path || !looksLikePath(path)) continue;
tokens.push({ start, end: start + 1 + path.length, path });
}
return tokens;
}
/** Path tokens as highlight ranges. */
export function pathHighlightRanges(text: string): HighlightRange[] {
return scanPaths(text).map((token) => ({
start: token.start,
end: token.end,
style: 'path' as const,
}));
}
@@ -0,0 +1,104 @@
/**
* The composer's prefix-token grammar: `/skill`, `/command` and `#snippet`.
*
* Structurally these are the same construct a sigil at a word boundary
* followed by an identifier and they were previously scanned by three
* different regexes per sigil (highlighting, send-time collection, and the
* autocomplete trigger), each with its own idea of the valid character set.
* The send-time skill scanner, for instance, accepted only lowercase names, so
* a `/My_Skill` token was painted as a command but never collected.
*
* Scanning is deliberately generous: it finds every syntactically plausible
* token and leaves the decision of what exists to the caller, which holds the
* authoritative set of commands, skills or snippets. Membership is the
* authority; the pattern is only a locator.
*
* @see mentions.ts for the `@` half of the grammar.
*/
/**
* Identifier body shared by all prefix tokens: starts alphanumeric, then
* alphanumerics, `-` and `_`. Kept in one place so `/` and `#` cannot drift
* apart again.
*/
const TOKEN_NAME = '[A-Za-z0-9][A-Za-z0-9_-]*';
/** Sigils that introduce a prefix token. */
export type TokenPrefix = '/' | '#';
export interface PrefixToken {
/** Offset of the sigil. */
start: number;
/** Offset just past the identifier. */
end: number;
/** The sigil that introduced this token. */
prefix: TokenPrefix;
/** The identifier without its sigil. */
name: string;
}
const SCANNERS: Record<TokenPrefix, RegExp> = {
'/': new RegExp(`(^|\\s)\\/(${TOKEN_NAME})`, 'g'),
'#': new RegExp(`(^|\\s)#(${TOKEN_NAME})`, 'g'),
};
/**
* Find every `prefix`-token in `text`. A token must sit at the start of the
* text or directly after whitespace, so `a/b` and `#1` inside `issue#1` stay
* ordinary prose.
*/
export function scanPrefixTokens(text: string, prefix: TokenPrefix): PrefixToken[] {
if (!text || !text.includes(prefix)) return [];
const scanner = SCANNERS[prefix];
const tokens: PrefixToken[] = [];
scanner.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = scanner.exec(text)) !== null) {
const name = match[2];
// The leading-whitespace capture keeps the boundary check inside the
// pattern; the token itself starts after it.
const start = match.index + match[1].length;
tokens.push({ start, end: start + 1 + name.length, prefix, name });
}
return tokens;
}
/**
* The tokens whose name is present in `known`, in document order. `compare`
* decides how a token name is matched against the set snippets and slash
* invocations both match case-insensitively, while the skill-instruction
* builder matches the exact registered name.
*/
export function filterKnownTokens(
tokens: readonly PrefixToken[],
known: ReadonlySet<string>,
compare: 'exact' | 'case-insensitive' = 'case-insensitive',
): PrefixToken[] {
if (known.size === 0) return [];
return tokens.filter((token) => known.has(
compare === 'exact' ? token.name : token.name.toLowerCase(),
));
}
/**
* Distinct names of the known `prefix`-tokens in `text`, in first-occurrence
* order. Used to tell the model which skills the user named explicitly.
*/
export function collectKnownTokenNames(
text: string,
prefix: TokenPrefix,
known: ReadonlySet<string>,
compare: 'exact' | 'case-insensitive' = 'case-insensitive',
): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const token of filterKnownTokens(scanPrefixTokens(text, prefix), known, compare)) {
if (seen.has(token.name)) continue;
seen.add(token.name);
names.push(token.name);
}
return names;
}
@@ -0,0 +1,95 @@
/**
* One pass over the composer text producing every highlight range.
*
* The composer used to derive its highlighting from six independent memos in
* ChatInput.tsx markdown, fenced-code syntax, mentions, slash tokens,
* snippet tokens and attachment citations each re-scanning the same string
* and each having to be remembered when a new construct was added. This is the
* single entry point: give it the text and what the composer knows about the
* workspace, get back the ranges.
*
* It is also the seam the editor renders through. The mirror overlay consumes
* these ranges via `buildHighlightParts`; a CodeMirror view maps the same
* ranges to decorations. Adding a construct to the language means adding it
* here, once.
*/
import { findAttachmentCitationRanges } from '../../attachmentCitations';
import { highlightFencedCode } from '../../composerCodeHighlight';
import {
mentionRangesToHighlightRanges,
tokenizeMarkdown,
type HighlightRange,
type MentionRange,
} from '../../composerHighlight';
import { classifyMention, scanMentions } from './mentions';
import { pathHighlightRanges } from './paths';
import { filterKnownTokens, scanPrefixTokens } from './prefixTokens';
/**
* What the composer knows about its workspace while tokenizing. Every set is
* authoritative: a token is only a reference if it resolves against one of
* them, so unknown `/tokens` and `@words` stay plain prose.
*/
export interface ComposerLanguageContext {
/** Shell mode (`!cmd`) is not the prompt language — nothing is tokenized. */
inputMode: 'normal' | 'shell';
/** Lowercased names of the agents that can be mentioned. */
knownAgentNames: ReadonlySet<string>;
/** Mention paths confirmed by the picker, a drop, or a restored draft. */
confirmedMentions: ReadonlySet<string>;
/** Lowercased command, skill and built-in names invocable with `/`. */
knownSlashNames: ReadonlySet<string>;
/** Lowercased snippet names and aliases invocable with `#`. */
knownSnippetTriggers: ReadonlySet<string>;
/** Filenames of the currently attached files, cited inline as `[name]`. */
attachmentFilenames: readonly string[];
}
/** Mention ranges alone — the composer also needs these to resolve references. */
export function tokenizeMentions(
text: string,
context: Pick<ComposerLanguageContext, 'knownAgentNames' | 'confirmedMentions'>,
): MentionRange[] {
const ranges: MentionRange[] = [];
for (const token of scanMentions(text)) {
const kind = classifyMention(token.name, context);
if (kind) ranges.push({ start: token.start, end: token.end, kind });
}
return ranges;
}
/**
* Every highlight range in `text`. Ranges may overlap; `buildHighlightParts`
* resolves them by priority.
*/
export function tokenizeComposer(
text: string,
context: ComposerLanguageContext,
): HighlightRange[] {
if (!text || context.inputMode === 'shell') return [];
const ranges: HighlightRange[] = [
...tokenizeMarkdown(text),
...highlightFencedCode(text),
...mentionRangesToHighlightRanges(tokenizeMentions(text, context)),
// `~path` is inert: highlighted for the reader, never attached.
...pathHighlightRanges(text),
];
for (const token of filterKnownTokens(scanPrefixTokens(text, '/'), context.knownSlashNames)) {
ranges.push({ start: token.start, end: token.end, style: 'mentionCommand' });
}
for (const token of filterKnownTokens(scanPrefixTokens(text, '#'), context.knownSnippetTriggers)) {
ranges.push({ start: token.start, end: token.end, style: 'mentionSnippet' });
}
if (context.attachmentFilenames.length > 0 && text.includes('[')) {
for (const range of findAttachmentCitationRanges(text, [...context.attachmentFilenames])) {
ranges.push({ ...range, style: 'mentionFile' });
}
}
return ranges;
}
@@ -0,0 +1,118 @@
/**
* Which autocomplete a caret position asks for.
*
* The composer has four pickers (command, skill, snippet, file/agent mention)
* and the rule that opens each of them used to be inlined in a single 90-line
* `updateAutocompleteState` callback, duplicating the boundary logic that
* `scanPrefixTokens` and `scanMentions` already own. This module answers the
* one question the composer actually asks "given the text and the caret,
* what should be open?" as a pure function, so the editor layer only has to
* report the caret and render the result.
*
* Exactly one trigger can be active, and order matters: the command palette
* (a leading `/`) outranks the inline skill picker, which outranks snippets,
* which outrank mentions. That precedence is the previous behavior, preserved.
*/
import {
getFileMentionAutocompleteQuery,
type FileMentionAutocompleteInputSource,
} from '../../fileMentionAutocompleteState';
export type AutocompleteKind = 'command' | 'skill' | 'snippet' | 'mention';
export interface AutocompleteTrigger {
kind: AutocompleteKind;
/** Text typed after the sigil, used to filter the picker. */
query: string;
}
export interface TriggerContext {
/** Shell mode (`!cmd`) disables every picker. */
inputMode: 'normal' | 'shell';
/** Whether the change that moved the caret came from a paste. */
inputSource?: FileMentionAutocompleteInputSource;
/** The text that change inserted, when known. */
insertedText?: string;
}
/**
* A sigil opens a picker only at a word boundary the start of the text or
* directly after whitespace. This mirrors `scanPrefixTokens`, but works
* backwards from the caret because the token is still being typed.
*/
const isWordBoundaryBefore = (text: string, index: number): boolean =>
index <= 0 || /\s/.test(text[index - 1]);
/**
* The command palette is reserved for a `/` in the very first column, with the
* caret still inside the command word and no argument typed yet. Once a space
* appears the message is a command invocation, not a search.
*/
function matchCommandPalette(value: string, cursorPosition: number): AutocompleteTrigger | null {
if (!value.startsWith('/')) return null;
const firstSpace = value.indexOf(' ');
if (firstSpace !== -1) return null;
const firstNewline = value.indexOf('\n');
const commandEnd = firstNewline === -1 ? value.length : firstNewline;
if (cursorPosition > commandEnd) return null;
return { kind: 'command', query: value.substring(1, commandEnd) };
}
/**
* An inline `/skill` or `#snippet` still being typed: the nearest sigil before
* the caret, at a word boundary, with no separator between it and the caret.
*/
function matchInlineToken(
value: string,
cursorPosition: number,
sigil: '/' | '#',
kind: AutocompleteKind,
): AutocompleteTrigger | null {
const textBeforeCursor = value.substring(0, cursorPosition);
const sigilIndex = textBeforeCursor.lastIndexOf(sigil);
if (sigilIndex === -1) return null;
if (!isWordBoundaryBefore(textBeforeCursor, sigilIndex)) return null;
const query = textBeforeCursor.substring(sigilIndex + 1);
if (query.includes(' ') || query.includes('\n')) return null;
return { kind, query };
}
/**
* Resolve the single autocomplete that the caret asks for, or null when none
* applies. Pure: the caller supplies the text and caret, and decides what to
* do with the answer.
*/
export function resolveAutocompleteTrigger(
value: string,
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
if (context.inputMode === 'shell') return null;
return matchCommandPalette(value, cursorPosition)
?? matchInlineToken(value, cursorPosition, '/', 'skill')
?? matchInlineToken(value, cursorPosition, '#', 'snippet')
?? matchMention(value, cursorPosition, context);
}
function matchMention(
value: string,
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
const query = getFileMentionAutocompleteQuery({
value,
cursorPosition,
inputSource: context.inputSource,
insertedText: context.insertedText,
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test';
import {
HISTORY_IDLE,
INITIAL_HISTORY_STATE,
stepNewer,
stepOlder,
type HistoryState,
} from '../useMessageHistory';
const HISTORY = ['newest', 'middle', 'oldest'];
/** Apply a sequence of steps, returning the texts shown and the final state. */
function walk(
steps: Array<{ dir: 'older' | 'newer'; draft?: string }>,
history: readonly string[] = HISTORY,
) {
let state: HistoryState = INITIAL_HISTORY_STATE;
const texts: Array<string | null> = [];
for (const step of steps) {
const result = step.dir === 'older'
? stepOlder(state, history, step.draft ?? '')
: stepNewer(state, history);
state = result.state;
texts.push(result.text);
}
return { texts, state };
}
describe('walking back', () => {
test('the first step recalls the most recent message', () => {
expect(walk([{ dir: 'older', draft: 'my draft' }]).texts).toEqual(['newest']);
});
test('successive steps go further back', () => {
expect(walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }]).texts)
.toEqual(['newest', 'middle', 'oldest']);
});
test('the oldest message is the end of the line', () => {
const { texts } = walk([
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
]);
expect(texts[3]).toBeNull();
});
test('reaching the end leaves the state where it was', () => {
const { state } = walk([
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
]);
expect(state.index).toBe(2);
});
test('empty history recalls nothing', () => {
const { texts, state } = walk([{ dir: 'older', draft: 'x' }], []);
expect(texts).toEqual([null]);
expect(state.index).toBe(HISTORY_IDLE);
});
test('a single-message history has exactly one step', () => {
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }], ['only']);
expect(texts).toEqual(['only', null]);
});
});
describe('coming back', () => {
test('returns toward newer messages', () => {
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'newer' }]);
expect(texts[2]).toBe('newest');
});
test('stepping past the newest restores the stashed draft', () => {
const { texts, state } = walk([
{ dir: 'older', draft: 'half-written prompt' },
{ dir: 'newer' },
]);
expect(texts[1]).toBe('half-written prompt');
expect(state.index).toBe(HISTORY_IDLE);
});
test('an empty draft is restored as empty rather than left on a message', () => {
const { texts } = walk([{ dir: 'older', draft: '' }, { dir: 'newer' }]);
expect(texts[1]).toBe('');
});
test('coming back when not browsing does nothing', () => {
expect(walk([{ dir: 'newer' }]).texts).toEqual([null]);
});
test('the draft is stashed on entry, not overwritten by recalled text', () => {
// The second `older` passes recalled text as the current text; it must
// not replace what the user actually typed.
const { texts } = walk([
{ dir: 'older', draft: 'original draft' },
{ dir: 'older', draft: 'newest' },
{ dir: 'newer' },
{ dir: 'newer' },
]);
expect(texts[3]).toBe('original draft');
});
test('the stash is cleared once restored', () => {
const { state } = walk([{ dir: 'older', draft: 'draft' }, { dir: 'newer' }]);
expect(state.stashedDraft).toBe('');
});
});
describe('a shrinking history', () => {
test('an index past the end of a shorter history cannot step further back', () => {
const state: HistoryState = { index: 5, stashedDraft: 'draft' };
expect(stepOlder(state, HISTORY, 'x').text).toBeNull();
});
});
@@ -0,0 +1,100 @@
/**
* Where an autocomplete popup goes in focus mode.
*
* In the normal composer each picker anchors to the composer's own edge, which
* is close enough to the text. In focus mode the composer fills the surface,
* so an edge-anchored picker would sit far from what the user is typing
* there it follows the caret instead.
*
* The editor reports the caret's viewport position directly, so this only has
* to decide whether the popup fits below the caret or has to flip above it,
* and keep it inside the composer horizontally.
*/
import React from 'react';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
import type { AutocompleteKind } from '../language/triggers';
import type { AutocompleteOverlayPosition } from '../ui/ComposerAutocompletePopups';
export interface AutocompletePositionOptions {
/** Only focus mode places popups at the caret. */
enabled: boolean;
openAutocomplete: AutocompleteKind | null;
/** Recompute whenever the text changes, since the caret moves with it. */
message: string;
editorRef: React.RefObject<ComposerEditorHandle | null>;
/** The composer box the popup is positioned within. */
containerRef: React.RefObject<HTMLElement | null>;
}
export function useAutocompletePosition(options: AutocompletePositionOptions) {
const { enabled, openAutocomplete, message, editorRef, containerRef } = options;
const [position, setPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
const update = React.useCallback(() => {
if (!enabled) {
setPosition(null);
return;
}
if (openAutocomplete === null) {
setPosition(null);
return;
}
const editor = editorRef.current;
const container = containerRef.current;
if (!editor || !container) return;
// The editor reports the caret's viewport position directly, so the
// popup no longer has to be placed from a hand-measured text mirror.
const caret = editor.caretCoords();
if (!caret) return;
const containerRect = container.getBoundingClientRect();
const caretY = caret.top - containerRect.top;
const caretX = caret.left - containerRect.left;
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 = openAutocomplete === 'mention' ? 520 : openAutocomplete === 'skill' ? 360 : 450;
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));
setPosition({
top: place === 'below' ? caretY + 22 : caretY - 6,
left: clampedLeft,
place,
maxHeight,
});
}, [containerRef, editorRef, enabled, openAutocomplete]);
React.useLayoutEffect(() => {
update();
}, [
update,
message,
openAutocomplete,
enabled,
]);
React.useEffect(() => {
if (!enabled) return;
const onResize = () => update();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [enabled, update]);
return { position, update };
}
@@ -0,0 +1,230 @@
/**
* Per-session draft persistence for the composer.
*
* A draft belongs to a (runtime, directory, session) identity. Switching any
* of those saves the outgoing draft and restores the incoming one, so moving
* between sessions never loses typed text and never leaks it into the wrong
* conversation.
*
* Writes are debounced while typing but forced at every edge where the page
* may stop running tab hidden, frozen, unloading, unmounting because a
* pending timer is not a saved draft.
*/
import React from 'react';
import {
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
} from '@/lib/chatDraftPersistence';
const PERSIST_DEBOUNCE_MS = 500;
/**
* Identifies a stored draft's content. Comparing signatures lets a repeated
* save of unchanged text skip the write entirely.
*/
function draftSignature(text: string, confirmedMentions: Iterable<string>): string {
// NUL separates the fields: no draft text can contain it, so two different
// (text, mentions) pairs can never produce the same signature.
return `${text}\u0000${[...confirmedMentions].sort().join('\u0000')}`;
}
export interface ComposerDraftOptions {
/** Current composer text. */
message: string;
/** Latest text without waiting for a render, for flush-on-unload paths. */
messageRef: React.RefObject<string>;
setMessage: (text: string) => void;
/**
* Mention paths the user confirmed through the picker. Mutated here:
* mentions no longer present in the text are dropped before saving.
*/
confirmedMentionsRef: React.RefObject<Set<string>>;
/** The draft this composer currently belongs to. */
identity: ChatDraftIdentity | null;
/** User setting: when off, drafts are discarded rather than stored. */
persistEnabled: boolean;
/** The draft restored on mount, if any. */
initialDraft: { text: string; identity: ChatDraftIdentity | null };
/** Called when the composer switches to a different draft identity. */
onIdentityChange?: () => void;
/** Called after a non-empty draft is restored, to select its text. */
onDraftRestored?: () => void;
}
export interface ComposerDraftControls {
/**
* Write a draft now, bypassing the debounce. Used on submit, where the
* cleared composer must be stored before the send resolves.
*/
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
}
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
const {
message,
messageRef,
setMessage,
confirmedMentionsRef,
identity,
persistEnabled,
initialDraft,
onIdentityChange,
onDraftRestored,
} = options;
const persistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const skipNextPersistRef = React.useRef(false);
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
// Callbacks reach the effects through a ref so a caller passing inline
// functions does not re-run the persistence effects on every render.
const callbacksRef = React.useRef({ onIdentityChange, onDraftRestored });
callbacksRef.current = { onIdentityChange, onDraftRestored };
React.useEffect(() => {
currentIdentityRef.current = identity;
}, [identity]);
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
if (!target) return;
const key = getChatDraftIdentityKey(target);
// Only keep confirmed mentions the draft still contains: a mention the
// user deleted must not resurrect as a file reference on restore.
const activeMentions = new Set<string>();
for (const mention of confirmedMentionsRef.current) {
if (draft.includes(`@${mention}`)) activeMentions.add(mention);
}
confirmedMentionsRef.current = activeMentions;
const signature = draftSignature(draft, activeMentions);
if (lastPersistedRef.current.get(key) === signature) return;
writeChatDraft(target, draft, activeMentions);
lastPersistedRef.current.set(key, signature);
}, [confirmedMentionsRef]);
const clearPending = React.useCallback(() => {
if (!persistTimerRef.current) return;
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}, []);
// Mount: a restored draft is selected so typing replaces it; with the
// setting off it is discarded instead of silently kept.
const handledInitialRef = React.useRef(false);
React.useEffect(() => {
if (handledInitialRef.current) return;
handledInitialRef.current = true;
if (!initialDraft.text) return;
if (!persistEnabled) {
setMessage('');
writeChatDraft(initialDraft.identity, '', []);
return;
}
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
// Runs once; the initial draft is captured at mount by design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistEnabled]);
// Identity switch: save the outgoing draft, load the incoming one.
const previousIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
React.useEffect(() => {
const previous = previousIdentityRef.current;
const previousKey = previous ? getChatDraftIdentityKey(previous) : null;
const currentKey = identity ? getChatDraftIdentityKey(identity) : null;
if (previousKey === currentKey) return;
previousIdentityRef.current = identity;
callbacksRef.current.onIdentityChange?.();
clearPending();
// The incoming draft is being written into state right now; the
// debounced effect must not immediately write it back out.
skipNextPersistRef.current = true;
if (!persistEnabled) {
setMessage('');
confirmedMentionsRef.current = new Set();
return;
}
persistNow(previous, messageRef.current);
const restored = readChatDraft(identity);
setMessage(restored.text);
confirmedMentionsRef.current = restored.confirmedMentions;
if (restored.text) {
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
}
}, [clearPending, confirmedMentionsRef, identity, messageRef, persistEnabled, persistNow, setMessage]);
// A draft deleted elsewhere (session deleted, drafts cleared) clears the
// composer if it is the one on screen.
React.useEffect(() => subscribeChatDraftDeletion((deleted) => {
const deletedKey = getChatDraftIdentityKey(deleted);
// Record the empty signature so a queued write does not resurrect it.
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
const current = currentIdentityRef.current;
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
clearPending();
skipNextPersistRef.current = true;
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
}), [clearPending, confirmedMentionsRef, messageRef, setMessage]);
// Debounced write while typing.
React.useEffect(() => {
if (!persistEnabled) {
clearPending();
persistNow(identity, '');
return;
}
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
return;
}
clearPending();
const draftSnapshot = message;
const identitySnapshot = identity;
persistTimerRef.current = setTimeout(() => {
persistTimerRef.current = null;
persistNow(identitySnapshot, draftSnapshot);
}, PERSIST_DEBOUNCE_MS);
return clearPending;
}, [clearPending, identity, message, persistEnabled, persistNow]);
// Force a write wherever the page may stop running before the timer fires.
React.useEffect(() => {
const flush = () => {
clearPending();
if (persistEnabled) persistNow(currentIdentityRef.current, messageRef.current);
};
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush();
};
document.addEventListener('visibilitychange', onVisibilityChange);
document.addEventListener('freeze', flush);
window.addEventListener('pagehide', flush);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('freeze', flush);
window.removeEventListener('pagehide', flush);
flush();
};
}, [clearPending, messageRef, persistEnabled, persistNow]);
return { persistNow };
}
@@ -0,0 +1,298 @@
/**
* Choosing where a new session will run.
*
* The new-session draft targets a project and a directory within it the
* project root or one of its worktrees. Both are discovered lazily: whether a
* project is even a git repository is unknown until asked, and its branch list
* is served stale-while-revalidate so a cached list appears instantly and
* refreshes behind it.
*
* The awkward part this hook contains is that the draft can point at a
* directory that does not exist yet a worktree being created. Such a
* directory must survive not appearing in the list, or the selector would snap
* back to the project root mid-creation and the session would be started in
* the wrong place.
*/
import React from 'react';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { formatDirectoryName } from '@/lib/utils';
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
export interface DraftTargetProject {
id: string;
path: string;
label?: string;
icon?: string | null;
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
}
/** A project's display name, falling back to its directory name. */
export function getProjectDisplayLabel(project: { label?: string; path: string }): string {
return project.label?.trim() || formatDirectoryName(project.path);
}
export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
const { git: runtimeGit } = useRuntimeAPIs();
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 (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
return;
}
void fetchGitStatus(selectedDraftProjectPath, runtimeGit, { silent: true });
}, [fetchGitStatus, runtimeGit, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, enabled]);
React.useEffect(() => {
if (!enabled || !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 isStale =
!selectedDraftProjectBranchesFetchedAt ||
Date.now() - selectedDraftProjectBranchesFetchedAt > 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, enabled]);
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]);
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]);
return {
projects,
selectedDraftProject,
selectedDraftProjectPath,
draftProjectLabel,
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
shouldShowDraftBranchSelector,
handleDraftProjectChange,
handleDraftDirectoryChange,
};
}
@@ -0,0 +1,99 @@
/**
* Walking back through previously sent messages with the arrow keys.
*
* Entering history stashes whatever was typed so leaving it returns the user's
* own text rather than the last recalled message the composer is not a
* terminal, and losing a half-written prompt to an arrow key is worse than not
* having history at all.
*
* Index 0 is the most recent message and higher indices are older, matching
* how the keys read: up goes further back.
*/
import React from 'react';
/** Not browsing history. */
export const HISTORY_IDLE = -1;
export interface HistoryState {
/** Index into the history, or HISTORY_IDLE when showing the user's draft. */
index: number;
/** The draft stashed on entry, restored on the way back out. */
stashedDraft: string;
}
export const INITIAL_HISTORY_STATE: HistoryState = { index: HISTORY_IDLE, stashedDraft: '' };
/**
* The outcome of an arrow key: the next state, and the text the composer
* should show. A null text means the key does nothing and the composer keeps
* what it has.
*/
export interface HistoryStep {
state: HistoryState;
text: string | null;
}
const unchanged = (state: HistoryState): HistoryStep => ({ state, text: null });
/** Step further back in history. `currentText` is stashed on entry. */
export function stepOlder(
state: HistoryState,
history: readonly string[],
currentText: string,
): HistoryStep {
if (history.length === 0) return unchanged(state);
if (state.index === HISTORY_IDLE) {
return { state: { index: 0, stashedDraft: currentText }, text: history[0] };
}
if (state.index >= history.length - 1) return unchanged(state);
const index = state.index + 1;
return { state: { ...state, index }, text: history[index] };
}
/** Step back toward the draft, restoring it once past the newest message. */
export function stepNewer(state: HistoryState, history: readonly string[]): HistoryStep {
if (state.index === HISTORY_IDLE) return unchanged(state);
if (state.index === 0) {
return { state: INITIAL_HISTORY_STATE, text: state.stashedDraft };
}
const index = state.index - 1;
return { state: { ...state, index }, text: history[index] };
}
export interface MessageHistory {
/** True while showing a recalled message rather than the user's draft. */
isBrowsing: boolean;
/** Recall an older message; returns null when already at the oldest. */
older: (currentText: string) => string | null;
/** Return toward the draft; returns null when not browsing. */
newer: () => string | null;
/** Leave history, discarding the stashed draft. Called after a send. */
reset: () => void;
}
export function useMessageHistory(history: readonly string[]): MessageHistory {
const [state, setState] = React.useState<HistoryState>(INITIAL_HISTORY_STATE);
const older = React.useCallback((currentText: string) => {
const step = stepOlder(state, history, currentText);
if (step.text === null) return null;
setState(step.state);
return step.text;
}, [history, state]);
const newer = React.useCallback(() => {
const step = stepNewer(state, history);
if (step.text === null) return null;
setState(step.state);
return step.text;
}, [history, state]);
const reset = React.useCallback(() => setState(INITIAL_HISTORY_STATE), []);
return { isBrowsing: state.index !== HISTORY_IDLE, older, newer, reset };
}
@@ -0,0 +1,435 @@
/**
* The mobile composer's pill full-composer state machine.
*
* With the keyboard closed the composer collapses into a narrow pill; any
* interaction expands it back. The swap is deliberately instant and
* synchronized with the keyboard choreography, so the chat compensates
* keyboard and composer height in a single motion rather than a staircase.
*
* Most of the code here is not the state machine itself but the corrections
* that keep it from fighting the platform: mobile browsers dismiss the
* keyboard on a tap before the click lands, iOS refuses programmatic focus
* outside a gesture, WebKit leaves the layout viewport panned after the
* keyboard hides, and overlay chains hand off through a frame where nothing
* is open. Every timeout and flushSync below marks one of those, and none of
* them is verifiable outside a real device.
*/
import React from 'react';
import { flushSync } from 'react-dom';
import { isCapacitorApp } from '@/lib/platform';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
/**
* Everything that must keep the composer expanded even with the keyboard
* down. Collapsing under an open sheet would unmount the focused editor and
* kill the keyboard the sheet is about to hand back.
*/
export interface MobileComposerHolders {
controlsPanelOpen: boolean;
attachMenuOpen: boolean;
draftPickerOpen: boolean;
issuePickerOpen: boolean;
prPickerOpen: boolean;
isDragging: boolean;
}
export interface MobileComposerShellOptions {
isMobile: boolean;
editorRef: React.RefObject<ComposerEditorHandle | null>;
formRef: React.RefObject<HTMLFormElement | null>;
setExpandedInput: (expanded: boolean) => void;
holders: MobileComposerHolders;
}
export interface MobileComposerShell {
/** The full composer is showing rather than the collapsed pill. */
expanded: boolean;
/** The editor has focus; the best keyboard proxy a browser offers. */
focused: boolean;
/** A MobileOverlayPanel is mounted in the shared portal root. */
overlayHostBusy: boolean;
dictationActive: boolean;
/** Expand and focus, synchronously, from inside a user gesture. */
expand: () => void;
onDictationActiveChange: (active: boolean) => void;
onEditorFocus: () => void;
onEditorBlur: () => void;
/** Suppress the keyboard restore when another overlay opens next. */
skipNextOverlayCloseRestore: () => void;
/** Cancel a pending keyboard restore entirely (a native picker takes over). */
cancelOverlayCloseRestore: () => void;
}
export function useMobileComposerShell(
options: MobileComposerShellOptions,
): MobileComposerShell {
const { isMobile, editorRef, formRef, setExpandedInput, holders } = options;
const [expanded, setExpanded] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [overlayHostBusy, setOverlayHostBusy] = React.useState(false);
const [dictationActive, setDictationActive] = React.useState(false);
// Set while an expansion is settling (focus or dictation not yet active) so
// the collapse watcher does not immediately fold it back into the pill.
const expandIntentRef = React.useRef<'focus' | null>(null);
const lastBlurAtRef = React.useRef(0);
const restoreKeyboardRef = React.useRef(false);
const blurTimerRef = React.useRef<number | null>(null);
React.useEffect(() => () => {
if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current);
}, []);
const expandedRef = React.useRef(expanded);
React.useEffect(() => {
expandedRef.current = expanded;
});
// The draft screen restructures itself around the composer: its starter
// chips leave once the full composer is up, and its centered title
// re-centers over whatever room remains. Announced as a root class from a
// layout effect so the restructure lands in the SAME frame as the pill
// swap — keyed on the keyboard instead (oc-keyboard-open arrives with the
// keyboardWillShow bridge event, ~100ms later), the chips vanished
// mid-rise as a second visible jump.
React.useLayoutEffect(() => {
if (!isMobile || typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('oc-composer-expanded', expanded);
return () => root.classList.remove('oc-composer-expanded');
}, [expanded, isMobile]);
const expand = React.useCallback(() => {
expandIntentRef.current = 'focus';
// flushSync so the editor exists NOW and focus() still runs inside the
// 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 or Chrome).
flushSync(() => setExpanded(true));
if (isCapacitorApp()) {
// Timing tuned on device, against WKWebView pausing frame
// presentation while the keyboard transition runs:
// - focus in the same task as the swap → the pause starts before
// the swap's first frame, so the pill stays on glass until the
// keyboard is nearly up;
// - focus two frames later → the swap is presented first and the
// keyboard only then begins, a visibly sequential two-step.
// Focusing INSIDE the first frame after the commit threads the
// needle: the swap's frame is already in the rendering pipeline
// when the keyboard transaction starts, so the keyboard rises from
// the tap and the composer appears during the rise. The Capacitor
// WebView raises the keyboard for a focus() outside the gesture
// task (browsers do not, hence the split); the choreography
// positions everything, so preventScroll stays on.
requestAnimationFrame(() => {
editorRef.current?.focus({ preventScroll: true });
});
return;
}
// Mobile browsers only open the soft keyboard for focus calls made
// synchronously from the tap; their native reveal is also the only
// thing that positions the composer, so no preventScroll.
editorRef.current?.focus({ preventScroll: false });
}, [editorRef]);
const onDictationActiveChange = React.useCallback((active: boolean) => {
setDictationActive(active);
if (active) {
expandIntentRef.current = null;
// Dictation went live, possibly from the pill: switch straight into
// the voice variant of the full composer.
if (!expandedRef.current) setExpanded(true);
return;
}
// Dictation ended. The insert flow hands focus back a tick later — if
// that happened, stay expanded; otherwise (cancel, discard,
// insert-and-send) collapse straight back to the pill rather than
// parking on the normal composer for the usual grace period.
window.setTimeout(() => {
if (!expandedRef.current) return;
if (editorRef.current?.isFocused()) return;
setExpanded(false);
setExpandedInput(false);
}, 30);
}, [editorRef, setExpandedInput]);
// Watch the shared overlay portal root: any mounted MobileOverlayPanel
// counts as busy. Observing the host catches overlays whose open state
// lives in other components without threading it through 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 = () => setOverlayHostBusy(hostEl.childElementCount > 0);
update();
const observer = new MutationObserver(update);
observer.observe(hostEl, { childList: true });
return () => observer.disconnect();
}, [isMobile]);
const overlayOpen = overlayHostBusy
|| holders.controlsPanelOpen
|| holders.attachMenuOpen
|| holders.issuePickerOpen
|| holders.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 live.
const pickerDialogsOpenRef = React.useRef(false);
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen;
const skipNextCloseRestoreRef = React.useRef(false);
const openSheetCountRef = React.useRef(0);
const holdFocusUntilRef = 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 cannot tell "this sheet going away"
// from "another sheet still up".
openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1);
if (skipNextCloseRestoreRef.current) {
skipNextCloseRestoreRef.current = false;
return;
}
if (!restoreKeyboardRef.current) return;
if (pickerDialogsOpenRef.current) return;
if (openSheetCountRef.current > 0) return;
restoreKeyboardRef.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 onEditorBlur).
holdFocusUntilRef.current = Date.now() + 600;
editorRef.current?.focus();
// The native focus lands mid-commit; React's delegated onFocus may
// not make it into this flush, leaving the composer un-busy for a
// beat — enough for the collapse timer to unmount the focused
// editor and kill the rising keyboard. Set the state explicitly.
if (editorRef.current?.isFocused()) setFocused(true);
// iOS reveals a field above the keyboard only for user-initiated
// focus; a programmatic one leaves the composer parked behind it.
// Reveal once the keyboard has mostly risen, and again after it
// settles.
const reveal = () => {
const editor = editorRef.current;
if (!editor?.isFocused()) return;
// Align the BOTTOM of the whole form with the visible bottom:
// revealing the editor alone leaves the footer icon row parked
// behind the keyboard accessory bar.
(formRef.current ?? editor.getScrollDOM())?.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);
};
}, [editorRef, formRef, isMobile]);
// If the keyboard was open (or closed moments ago by the overlay's own
// blur) when an overlay appeared, bring it back once every overlay is gone.
React.useEffect(() => {
if (!isMobile) return;
if (overlayOpen) {
if (focused || Date.now() - lastBlurAtRef.current < 800) {
restoreKeyboardRef.current = true;
}
return;
}
if (!restoreKeyboardRef.current) return;
// Debounced: overlay chains hand off with a frame of "nothing open"
// between steps (attach sheet closes, then the picker opens). 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(() => {
restoreKeyboardRef.current = false;
// Browsers need their native scroll-into-view (see expand).
editorRef.current?.focus({ preventScroll: isCapacitorApp() });
}, 180);
return () => window.clearTimeout(timer);
}, [editorRef, focused, isMobile, overlayOpen]);
// Fold back into the pill once nothing keeps the composer open. The short
// delay bridges focus moving between composer controls.
const busy = focused
|| overlayHostBusy
|| dictationActive
|| holders.controlsPanelOpen
|| holders.attachMenuOpen
|| holders.draftPickerOpen
|| holders.issuePickerOpen
|| holders.prPickerOpen
|| holders.isDragging;
React.useEffect(() => {
if (!isMobile || !expanded || busy) return;
const timer = window.setTimeout(() => {
// Authoritative DOM check: the React focus state can lag a
// programmatic refocus (the overlay-close restore above).
// Collapsing would unmount the focused editor and kill the keyboard.
if (editorRef.current?.isFocused()) return;
expandIntentRef.current = null;
setExpanded(false);
setExpandedInput(false);
}, 250);
return () => window.clearTimeout(timer);
}, [busy, editorRef, expanded, isMobile, setExpandedInput]);
const busyRef = React.useRef(false);
busyRef.current = busy;
// Browser counterpart of Capacitor's oc-keyboard-open root class (which is
// driven by native keyboard events): the focused composer 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 (focused) {
root.classList.add('oc-browser-keyboard-open');
} else {
root.classList.remove('oc-browser-keyboard-open');
// Installed PWA: after the keyboard dismisses, WebKit can leave the
// layout viewport stuck smaller or panned (content shifted up with
// a dead strip at the bottom) until something forces a recompute. A
// zero scroll after the exit animation settles snaps it back, and
// is 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');
}, [focused, isMobile]);
// 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 and composer shrink are measured — and
// compensated — as one motion instead of a two-step staircase. The delayed
// effect above remains 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 (!expandedRef.current) return;
// Something still holds the composer open (dictation, an overlay
// that closed the keyboard, a drag) — the fallback path handles it.
if (busyRef.current) return;
expandIntentRef.current = null;
flushSync(() => {
setExpanded(false);
setExpandedInput(false);
});
};
window.addEventListener('oc:keyboard-intent', handleIntent);
return () => window.removeEventListener('oc:keyboard-intent', handleIntent);
}, [isMobile, setExpandedInput]);
const onEditorFocus = React.useCallback(() => {
if (!isMobile) return;
if (blurTimerRef.current !== null) {
window.clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
expandIntentRef.current = null;
setFocused(true);
}, [isMobile]);
const onEditorBlur = React.useCallback(() => {
if (!isMobile) return;
// Focus hold after an overlay-close restore: iOS may retract the rising
// keyboard as the closing tap settles — take the focus right back
// instead of accepting the blur.
if (Date.now() < holdFocusUntilRef.current) {
const editor = editorRef.current;
if (editor) {
editor.focus();
window.setTimeout(() => {
if (Date.now() < holdFocusUntilRef.current && !editor.isFocused()) {
editor.focus();
}
}, 50);
return;
}
}
lastBlurAtRef.current = Date.now();
// Mobile browsers and installed PWAs share a blur race: the
// keyboard-dismiss reflow moves composer buttons before the tap's
// synthesized click lands, so the click misses its target. Capacitor's
// WebView does not need the hold — but it DOES need the state committed
// synchronously: the oc:keyboard-intent collapse arrives a few
// milliseconds after this blur on a setTimeout(0), and React's own
// scheduling can lose that race, leaving busyRef stale — the intent
// handler then skips the instant collapse and the pill appears only
// via the 250ms fallback, well after the keyboard has gone.
if (isCapacitorApp()) {
flushSync(() => setFocused(false));
return;
}
if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current);
// 120ms outlives the tap's synthesized click (which lands within a few
// ms of the blur) while keeping the composer's return visually tied to
// the keyboard dismissal.
blurTimerRef.current = window.setTimeout(() => {
blurTimerRef.current = null;
setFocused(false);
}, 120);
}, [editorRef, isMobile]);
const skipNextOverlayCloseRestore = React.useCallback(() => {
skipNextCloseRestoreRef.current = true;
}, []);
const cancelOverlayCloseRestore = React.useCallback(() => {
restoreKeyboardRef.current = false;
}, []);
return {
expanded,
focused,
overlayHostBusy,
dictationActive,
expand,
onDictationActiveChange,
onEditorFocus,
onEditorBlur,
skipNextOverlayCloseRestore,
cancelOverlayCloseRestore,
};
}
@@ -0,0 +1,146 @@
/**
* Pinning the composer to the visual viewport in mobile browsers.
*
* Capacitor has a keyboard choreography that resizes the shell, so the
* composer stays where it belongs on its own. A mobile browser has nothing of
* the sort: Safari pans the visual viewport over an unchanged layout instead
* of shrinking it, so a composer positioned in normal flow ends up partly
* off-screen or behind the keyboard. Both effects here exist to put it back,
* and both are deliberately restricted to non-Capacitor mobile.
*
* Neither is verifiable from a test: they are corrections for specific WebKit
* behaviors, and every guard in them marks a case that was observed breaking.
*/
import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
export interface MobileViewportPinOptions {
isMobile: boolean;
/** Composer expanded to fullscreen on mobile. */
isFullscreen: boolean;
/** The new-session draft screen is showing. */
isDraftScreen: boolean;
/** The composer has focus, i.e. the keyboard is up. */
isFocused: boolean;
formRef: React.RefObject<HTMLFormElement | null>;
editorRef: React.RefObject<ComposerEditorHandle | null>;
}
/** Clear every style the pin writes, returning the form to normal flow. */
function releaseForm(form: HTMLFormElement): void {
form.style.position = '';
form.style.left = '';
form.style.right = '';
form.style.width = '';
form.style.top = '';
form.style.height = '';
form.style.zIndex = '';
form.style.background = '';
}
export function useMobileViewportPin(options: MobileViewportPinOptions): void {
const { isMobile, isFullscreen, isDraftScreen, isFocused, formRef, editorRef } = options;
// Fullscreen: fix the form over the whole visible viewport and track the pan.
React.useLayoutEffect(() => {
if (!isMobile || !isFullscreen || isCapacitorApp()) return;
const vv = window.visualViewport;
const form = formRef.current;
const editor = editorRef.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));
// Stale-visualViewport guard: 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');
releaseForm(form);
// Back in flow: the browser panned for the fullscreen session and
// will not re-reveal the still-focused field on its own, which left
// the composer parked behind the keyboard.
requestAnimationFrame(() => {
if (editor?.isFocused()) {
editor.getScrollDOM()?.scrollIntoView({ block: 'nearest' });
}
});
};
}, [editorRef, formRef, isFullscreen, isMobile]);
// Draft screen with the keyboard up: anchor the normal-height composer to
// the visible bottom. The chat screen does not need this — its own
// focused-field reveal works there.
React.useLayoutEffect(() => {
if (!isMobile || isCapacitorApp()) return;
if (!isDraftScreen || isFullscreen || !isFocused) return;
const vv = window.visualViewport;
const form = formRef.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);
releaseForm(form);
};
}, [formRef, isDraftScreen, isFocused, isFullscreen, isMobile]);
}
@@ -0,0 +1,248 @@
import { describe, expect, test } from 'bun:test';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import {
buildOutgoingMessage,
type OutgoingMessageDeps,
type OutgoingMessageInput,
} from '../buildOutgoingMessage';
const attachment = (id: string) => ({ id, filename: `${id}.txt` } as unknown as AttachedFile);
/**
* Resolvers with just enough behavior to observe ordering: `@agent:name`
* names an agent, `@file:x` resolves to an attachment, `/skill` is a skill.
*/
const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps => ({
parseAgentMention: (text) => {
const match = /@agent:(\w+)\s*/.exec(text);
return match
? { text: text.replace(match[0], ''), agentName: match[1] }
: { text };
},
extractFileMentions: (text) => {
const attachments = [...text.matchAll(/@file:(\w+)/g)].map((m) => attachment(m[1]));
return { text, attachments };
},
sanitizeAttachments: (files) => [...(files ?? [])],
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
...overrides,
});
const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageInput => ({
queued: [],
composerText: null,
composerAttachments: [],
inlineComments: [],
syntheticTexts: [],
linkedIssueContext: null,
linkedPr: null,
...overrides,
});
describe('the composer text alone', () => {
test('becomes the primary message', () => {
const result = buildOutgoingMessage(input({ composerText: 'hello' }), deps());
expect(result.primaryText).toBe('hello');
expect(result.additionalParts).toEqual([]);
expect(result.isEmpty).toBe(false);
});
test('surrounding blank lines are trimmed', () => {
expect(buildOutgoingMessage(input({ composerText: '\n\nhello\n\n' }), deps()).primaryText)
.toBe('hello');
});
test('interior blank lines are preserved', () => {
expect(buildOutgoingMessage(input({ composerText: 'a\n\nb' }), deps()).primaryText)
.toBe('a\n\nb');
});
test('its attachments and resolved file mentions travel with it', () => {
const result = buildOutgoingMessage(
input({ composerText: 'see @file:doc', composerAttachments: [attachment('pic')] }),
deps(),
);
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['pic', 'doc']);
});
test('nothing at all is empty', () => {
expect(buildOutgoingMessage(input(), deps()).isEmpty).toBe(true);
});
});
describe('queued messages', () => {
test('the oldest becomes primary and the rest follow in order', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'first' }, { content: 'second' }, { content: 'third' }],
}), deps());
expect(result.primaryText).toBe('first');
expect(result.additionalParts.map((p) => p.text)).toEqual(['second', 'third']);
});
test('the composer text lands after everything queued', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'queued' }],
composerText: 'typed now',
}), deps());
expect(result.primaryText).toBe('queued');
expect(result.additionalParts.map((p) => p.text)).toEqual(['typed now']);
});
test('each queued message keeps its own attachments', () => {
const result = buildOutgoingMessage(input({
queued: [
{ content: 'a', attachments: [attachment('one')] },
{ content: 'b', attachments: [attachment('two')] },
],
}), deps());
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['one']);
expect(result.additionalParts[0].attachments?.map((a) => a.id)).toEqual(['two']);
});
});
describe('agent mentions', () => {
test('an agent named in the composer routes the send', () => {
expect(buildOutgoingMessage(input({ composerText: '@agent:build do it' }), deps())
.agentMentionName).toBe('build');
});
test('the first mention wins across queued messages', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: '@agent:plan a' }, { content: '@agent:build b' }],
}), deps());
expect(result.agentMentionName).toBe('plan');
});
test('a queued mention outranks one typed later', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: '@agent:plan a' }],
composerText: '@agent:build b',
}), deps());
expect(result.agentMentionName).toBe('plan');
});
test('no mention leaves the routing unset', () => {
expect(buildOutgoingMessage(input({ composerText: 'plain' }), deps()).agentMentionName)
.toBe(undefined);
});
});
describe('inline comments', () => {
test('attach to the composer text when nothing was queued', () => {
const result = buildOutgoingMessage(input({
composerText: 'body',
inlineComments: [{}, {}],
}), deps());
expect(result.primaryText).toBe('body\n[2 comments]');
});
test('attach to the last authored part when messages were queued', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'queued' }],
composerText: 'typed',
inlineComments: [{}],
}), deps());
expect(result.primaryText).toBe('queued');
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
});
test('fall back to primary when the queue produced no additional parts', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'only queued' }],
inlineComments: [{}],
}), deps());
expect(result.primaryText).toBe('only queued\n[1 comments]');
});
test('no comments changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
.toBe('body');
});
});
describe('synthetic context', () => {
test('a linked PR sends its instructions before its diff', () => {
const result = buildOutgoingMessage(input({
composerText: 'review this',
linkedPr: { instructions: 'how to read it', context: 'the diff' },
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['how to read it', 'the diff']);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
});
test('a linked issue is sent as context', () => {
const result = buildOutgoingMessage(input({
composerText: 'fix it',
linkedIssueContext: 'issue body',
}), deps());
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
});
test('synthetic texts precede the linked references', () => {
const result = buildOutgoingMessage(input({
composerText: 'x',
syntheticTexts: ['conflict note'],
linkedIssueContext: 'issue body',
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['conflict note', 'issue body']);
});
test('skills named inline are collected into a trailing instruction', () => {
const result = buildOutgoingMessage(input({ composerText: 'use /deploy now' }), deps());
expect(result.additionalParts.at(-1)).toEqual({ text: 'use: deploy', synthetic: true });
});
test('skills are collected across every authored body, without duplicates', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: '/deploy a' }],
composerText: '/deploy and /audit',
}), deps());
expect(result.additionalParts.at(-1)?.text).toBe('use: deploy,audit');
});
test('no skills means no instruction', () => {
const result = buildOutgoingMessage(input({ composerText: 'plain text' }), deps());
expect(result.additionalParts).toEqual([]);
});
test('context alone is still worth sending', () => {
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
expect(result.isEmpty).toBe(false);
});
test('attachments alone are worth sending', () => {
const result = buildOutgoingMessage(
input({ composerText: '', composerAttachments: [attachment('pic')] }),
deps(),
);
expect(result.isEmpty).toBe(false);
});
});
describe('full assembly order', () => {
test('queued, then typed, then synthetic, then references, then skills', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'q1' }, { content: 'q2' }],
composerText: 'typed /deploy',
syntheticTexts: ['synthetic'],
linkedIssueContext: 'issue',
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
}), deps());
expect(result.primaryText).toBe('q1');
expect(result.additionalParts.map((p) => p.text)).toEqual([
'q2',
'typed /deploy',
'synthetic',
'issue',
'pr-how',
'pr-diff',
'use: deploy',
]);
});
});
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import {
buildCommandVariables,
canRunCommand,
findMagicPromptCommand,
MAGIC_PROMPT_COMMANDS,
parseSlashCommand,
} from '../slashCommands';
describe('parseSlashCommand', () => {
test('reads a bare command', () => {
expect(parseSlashCommand('/explore')).toEqual({ name: 'explore', argument: '' });
});
test('reads a command with an argument', () => {
expect(parseSlashCommand('/summary rate limiting'))
.toEqual({ name: 'summary', argument: 'rate limiting' });
});
test('leading whitespace is tolerated', () => {
expect(parseSlashCommand(' /debug')).toEqual({ name: 'debug', argument: '' });
});
test('the name is lowercased but the argument keeps its casing', () => {
expect(parseSlashCommand('/Summary Rate Limiting'))
.toEqual({ name: 'summary', argument: 'Rate Limiting' });
});
test('a multi-line argument is preserved', () => {
expect(parseSlashCommand('/craft-goal line one\nline two'))
.toEqual({ name: 'craft-goal', argument: 'line one\nline two' });
});
test('ordinary prose is not a command', () => {
expect(parseSlashCommand('explore the code')).toBeNull();
expect(parseSlashCommand('see src/a.ts')).toBeNull();
expect(parseSlashCommand('')).toBeNull();
});
test('a bare slash is not a command', () => {
expect(parseSlashCommand('/')).toBeNull();
expect(parseSlashCommand('/ ')).toBeNull();
});
});
describe('findMagicPromptCommand', () => {
test('finds a registered command', () => {
expect(findMagicPromptCommand('explore')?.name).toBe('explore');
});
test('commands handled elsewhere are not prompt-pair commands', () => {
// undo/redo/timeline/compact/handoff-review manipulate state or open
// UI rather than sending a message.
expect(findMagicPromptCommand('undo')).toBeNull();
expect(findMagicPromptCommand('timeline')).toBeNull();
expect(findMagicPromptCommand('compact')).toBeNull();
});
test('an unknown name finds nothing', () => {
expect(findMagicPromptCommand('nope')).toBeNull();
});
});
describe('canRunCommand', () => {
const summary = findMagicPromptCommand('summary')!;
const explore = findMagicPromptCommand('explore')!;
test('summarizing needs an existing conversation', () => {
expect(canRunCommand(summary, { hasSession: true, hasDraft: false })).toBe(true);
expect(canRunCommand(summary, { hasSession: false, hasDraft: true })).toBe(false);
});
test('most commands also run from a new-session draft', () => {
expect(canRunCommand(explore, { hasSession: false, hasDraft: true })).toBe(true);
expect(canRunCommand(explore, { hasSession: true, hasDraft: false })).toBe(true);
});
test('nothing runs with neither', () => {
expect(canRunCommand(explore, { hasSession: false, hasDraft: false })).toBe(false);
expect(canRunCommand(summary, { hasSession: false, hasDraft: false })).toBe(false);
});
});
describe('buildCommandVariables', () => {
test('a command without an argument contributes no variables', () => {
expect(buildCommandVariables(findMagicPromptCommand('explore')!, ''))
.toEqual({ visible: {}, instructions: {} });
});
test('a summary topic reaches both prompts', () => {
const variables = buildCommandVariables(findMagicPromptCommand('summary')!, 'auth');
expect(variables.visible.topic_line).toBe(' focused on: auth');
expect(variables.instructions.topic_block).toContain('auth');
});
test('an absent summary topic leaves both slots blank, not "undefined"', () => {
const variables = buildCommandVariables(findMagicPromptCommand('summary')!, '');
expect(variables.visible.topic_line).toBe('');
expect(variables.instructions.topic_block).toBe('');
});
test('an idea is formatted as its own block', () => {
const variables = buildCommandVariables(findMagicPromptCommand('craft-goal')!, 'a CLI');
expect(variables.visible.idea_block).toBe('\n\nHere is my initial idea:\na CLI');
});
test('an absent idea leaves the slot blank', () => {
expect(buildCommandVariables(findMagicPromptCommand('schedule-task')!, '').visible.idea_block)
.toBe('');
});
});
describe('the command table', () => {
test('names are unique', () => {
const names = MAGIC_PROMPT_COMMANDS.map((command) => command.name);
expect(new Set(names).size).toBe(names.length);
});
test('every command names both prompts and a failure toast', () => {
for (const command of MAGIC_PROMPT_COMMANDS) {
expect(command.visiblePrompt.startsWith('session.')).toBe(true);
expect(command.instructionsPrompt.startsWith('session.')).toBe(true);
expect(command.errorToastKey.startsWith('chat.chatInput.toast.')).toBe(true);
}
});
});
@@ -0,0 +1,178 @@
/**
* Assembling what the composer actually sends.
*
* A single send can carry more than what the user just typed: messages queued
* while the previous turn ran, inline review comments, `@file` references
* resolved to attachments, a linked GitHub issue or PR, synthetic parts from
* conflict resolution, and an instruction naming the skills mentioned inline.
*
* OpenCode takes one primary message plus additional parts, so all of that has
* to be flattened into that shape and the flattening has rules that are easy
* to get wrong and impossible to see when they are spread through a 400-line
* handler. They are stated here, as a pure function over injected resolvers,
* so the ordering can be tested rather than trusted.
*/
import type { AttachedFile } from '@/stores/types/sessionTypes';
export interface OutgoingPart {
text: string;
attachments?: AttachedFile[];
/** Synthetic parts are context for the model, not shown as user content. */
synthetic?: boolean;
}
export interface OutgoingMessage {
primaryText: string;
primaryAttachments: AttachedFile[];
additionalParts: OutgoingPart[];
/** The agent the first `@agent` mention routed to, if any. */
agentMentionName?: string;
/** True when there is nothing worth sending. */
isEmpty: boolean;
}
export interface QueuedInput {
content: string;
attachments?: AttachedFile[];
}
export interface OutgoingMessageInput {
/** Messages queued while a turn was running, oldest first. */
queued: readonly QueuedInput[];
/** The composer's own text, or null when this send skips it. */
composerText: string | null;
composerAttachments: readonly AttachedFile[];
/** Inline review comments, appended to the user's last authored text. */
inlineComments: readonly unknown[];
/** Synthetic context produced elsewhere (conflict resolution, and such). */
syntheticTexts: readonly string[];
linkedIssueContext: string | null;
linkedPr: { instructions: string; context: string } | null;
}
/**
* The parts of assembly that depend on stores or async config, injected so the
* assembly itself stays pure.
*/
export interface OutgoingMessageDeps {
/** Strip a leading `@agent` mention and report which agent it named. */
parseAgentMention: (text: string) => { text: string; agentName?: string };
/** Resolve `@path` references into server-side attachments. */
extractFileMentions: (text: string) => { text: string; attachments: AttachedFile[] };
/** Normalize attachments for transport (server paths become file URLs). */
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
/** Skills named inline with `/name`. */
collectSkillNames: (text: string) => string[];
/** Append inline review comments to a message body. */
appendComments: (text: string, comments: readonly unknown[]) => string;
/** Instruction telling the model which skills the user named. */
buildSkillInstruction: (names: string[]) => string | null;
}
export function buildOutgoingMessage(
input: OutgoingMessageInput,
deps: OutgoingMessageDeps,
): OutgoingMessage {
let primaryText = '';
let primaryAttachments: AttachedFile[] = [];
let agentMentionName: string | undefined;
const additionalParts: OutgoingPart[] = [];
const skillNames: string[] = [];
const noteSkills = (text: string) => {
for (const name of deps.collectSkillNames(text)) {
if (!skillNames.includes(name)) skillNames.push(name);
}
};
/** The first agent mention encountered wins; later ones are ignored. */
const noteAgent = (name?: string) => {
if (!agentMentionName && name) agentMentionName = name;
};
/** Run a body through mention parsing, collecting its side effects. */
const resolve = (raw: string) => {
const agent = deps.parseAgentMention(raw);
noteAgent(agent.agentName);
const mentions = deps.extractFileMentions(agent.text);
noteSkills(mentions.text);
return mentions;
};
// Queued messages come first, in the order they were queued: the oldest
// becomes the primary message so the turn reads chronologically.
input.queued.forEach((queued, index) => {
const resolved = resolve(queued.content);
const attachments = [
...deps.sanitizeAttachments(queued.attachments),
...resolved.attachments,
];
if (index === 0) {
primaryText = resolved.text;
primaryAttachments = attachments;
return;
}
additionalParts.push({ text: resolved.text, attachments });
});
// The composer's own text follows, becoming primary only when nothing was
// queued ahead of it.
if (input.composerText !== null) {
const resolved = resolve(input.composerText.replace(/^\n+|\n+$/g, ''));
const attachments = [
...deps.sanitizeAttachments(input.composerAttachments),
...resolved.attachments,
];
if (input.queued.length === 0) {
primaryText = resolved.text;
primaryAttachments = attachments;
} else {
additionalParts.push({ text: resolved.text, attachments });
}
}
// Inline comments attach to the last thing the user authored, so they read
// as a continuation of it rather than as a separate turn.
if (input.inlineComments.length > 0) {
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
? additionalParts[additionalParts.length - 1]
: null;
if (lastAuthored) {
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
} else {
primaryText = deps.appendComments(primaryText, input.inlineComments);
}
}
// Everything below is context for the model, never user-visible content.
for (const text of input.syntheticTexts) {
additionalParts.push({ text, synthetic: true });
}
if (input.linkedIssueContext) {
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
}
if (input.linkedPr) {
// Instructions before context: the model is told how to read the diff
// before it is given the diff.
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
}
const skillInstruction = deps.buildSkillInstruction(skillNames);
if (skillInstruction) {
additionalParts.push({ text: skillInstruction, synthetic: true });
}
return {
primaryText,
primaryAttachments,
additionalParts,
agentMentionName,
isEmpty: !primaryText && primaryAttachments.length === 0 && additionalParts.length === 0,
};
}
@@ -0,0 +1,182 @@
/**
* The composer's local slash commands.
*
* Most of them do the same thing: render a pair of magic prompts one the
* user sees, one the model is instructed with and send them as a single
* message. That shape was previously written out nine times as an `else if`
* chain, so adding a command meant copying twenty lines and remembering to
* change every string in them. Here the shape is the executor and the
* commands are data.
*
* Commands that are not "send a prompt pair" (undo, redo, timeline, compact,
* handoff-review) stay with the composer: they manipulate session state or
* open UI rather than producing a message.
*/
import type { I18nKey } from '@/lib/i18n';
import type { MagicPromptId } from '@/lib/magicPrompts';
/** What a command needs before it can run. */
export type CommandRequirement = 'session' | 'session-or-draft';
export interface MagicPromptCommand {
/** The name typed after the slash. */
name: string;
/** Magic prompt shown to the user as their message. */
visiblePrompt: MagicPromptId;
/** Magic prompt attached as synthetic instructions for the model. */
instructionsPrompt: MagicPromptId;
/** i18n key for the toast shown when the command fails. */
errorToastKey: I18nKey;
requires: CommandRequirement;
/**
* Turn the text typed after the command name into template variables.
* Commands without an argument omit this.
*/
buildVariables?: (argument: string) => {
visible?: Record<string, string>;
instructions?: Record<string, string>;
};
}
/**
* `/summary rate limiting` focuses the summary on that topic. The topic is
* woven into the visible message as a phrase and into the instructions as a
* directive, so both read naturally when it is absent.
*/
const summaryVariables = (topic: string) => ({
visible: { topic_line: topic ? ` focused on: ${topic}` : '' },
instructions: {
topic_block: topic
? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.`
: '',
},
});
/** `/craft-goal <idea>` and `/schedule-task <idea>` seed the prompt with the idea. */
const ideaVariables = (idea: string) => ({
visible: { idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '' },
});
export const MAGIC_PROMPT_COMMANDS: readonly MagicPromptCommand[] = [
{
name: 'summary',
visiblePrompt: 'session.summary.visible',
instructionsPrompt: 'session.summary.instructions',
errorToastKey: 'chat.chatInput.toast.summaryFailed',
// Summarizing needs a conversation to summarize.
requires: 'session',
buildVariables: summaryVariables,
},
{
name: 'workspace-review',
visiblePrompt: 'session.review.visible',
instructionsPrompt: 'session.review.instructions',
errorToastKey: 'chat.chatInput.toast.reviewFailed',
requires: 'session-or-draft',
},
{
name: 'plan-feature',
visiblePrompt: 'session.plan.visible',
instructionsPrompt: 'session.plan.instructions',
errorToastKey: 'chat.chatInput.toast.planFeatureFailed',
requires: 'session-or-draft',
},
{
name: 'craft-goal',
visiblePrompt: 'session.craftGoal.visible',
instructionsPrompt: 'session.craftGoal.instructions',
errorToastKey: 'chat.chatInput.toast.craftGoalFailed',
requires: 'session-or-draft',
buildVariables: ideaVariables,
},
{
name: 'schedule-task',
visiblePrompt: 'session.scheduleTask.visible',
instructionsPrompt: 'session.scheduleTask.instructions',
errorToastKey: 'chat.chatInput.toast.scheduleTaskFailed',
requires: 'session-or-draft',
buildVariables: ideaVariables,
},
{
name: 'catch-up',
visiblePrompt: 'session.catchup.visible',
instructionsPrompt: 'session.catchup.instructions',
errorToastKey: 'chat.chatInput.toast.catchUpFailed',
requires: 'session-or-draft',
},
{
name: 'debug',
visiblePrompt: 'session.debug.visible',
instructionsPrompt: 'session.debug.instructions',
errorToastKey: 'chat.chatInput.toast.debugFailed',
requires: 'session-or-draft',
},
{
name: 'weigh',
visiblePrompt: 'session.weigh.visible',
instructionsPrompt: 'session.weigh.instructions',
errorToastKey: 'chat.chatInput.toast.weighFailed',
requires: 'session-or-draft',
},
{
name: 'explore',
visiblePrompt: 'session.explore.visible',
instructionsPrompt: 'session.explore.instructions',
errorToastKey: 'chat.chatInput.toast.exploreFailed',
requires: 'session-or-draft',
},
];
const COMMANDS_BY_NAME = new Map(MAGIC_PROMPT_COMMANDS.map((command) => [command.name, command]));
export interface ParsedSlashCommand {
name: string;
/** Everything typed after the command name, trimmed. */
argument: string;
}
/**
* Read the leading slash command out of a message, if there is one. Only the
* first word counts as the command; the rest is its argument.
*/
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
const trimmed = text.trimStart();
if (!trimmed.startsWith('/')) return null;
const withoutSlash = trimmed.slice(1);
const name = withoutSlash.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
if (!name) return null;
return {
name,
argument: withoutSlash.slice(name.length).trim(),
};
}
/** The prompt-pair command for `name`, or null when it is not one. */
export function findMagicPromptCommand(name: string): MagicPromptCommand | null {
return COMMANDS_BY_NAME.get(name) ?? null;
}
/** Whether the current session state satisfies the command's requirement. */
export function canRunCommand(
command: MagicPromptCommand,
state: { hasSession: boolean; hasDraft: boolean },
): boolean {
return command.requires === 'session'
? state.hasSession
: state.hasSession || state.hasDraft;
}
/** The template variables for both prompts of a command invocation. */
export function buildCommandVariables(
command: MagicPromptCommand,
argument: string,
): { visible: Record<string, string>; instructions: Record<string, string> } {
const built = command.buildVariables?.(argument) ?? {};
return {
visible: built.visible ?? {},
instructions: built.instructions ?? {},
};
}
@@ -0,0 +1,106 @@
/**
* Text-splicing rules for the composer.
*
* Everything that inserts into the prompt dictation, preset chips, pasted
* images, dropped files, revert-to-message has to decide how the new text
* meets the text already there. These are those decisions, kept together and
* away from the component so they can be reasoned about (and tested) as plain
* string functions.
*/
/**
* Append `next` as its own block, separated by a blank line, and leave a blank
* line after it so the user's caret starts on a fresh paragraph. Used when the
* inserted text is a self-contained chunk (a reverted message, a quoted
* excerpt) rather than a continuation of the sentence.
*/
export function 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}`;
}
/**
* Append `next` to the end of the current sentence, with exactly one space
* between them and a trailing space so the user can keep typing. Used for
* dictation and for file mentions added from a drop.
*/
export function 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} `;
}
/**
* Pad an insertion so it does not fuse with its neighbours, without adding
* space where the surrounding punctuation already reads correctly: no space
* after an opening bracket, none before a closing one or before sentence
* punctuation.
*/
export function 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 ? ' ' : ''}`;
}
/**
* Pasting an image alongside text: the citation goes after whatever text came
* with it, separated by a space.
*/
export function buildImagePasteInsertion(pastedText: string, citationText: string): string {
if (!pastedText) {
return citationText;
}
return `${pastedText}${/\s$/.test(pastedText) ? '' : ' '}${citationText}`;
}
/**
* A single-line URL pasted over a selection becomes a markdown link rather
* than replacing the selected text.
*/
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
/**
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
* containing whitespace is not one, and text that already looks like a link
* is left alone rather than nested.
*/
export function shouldWrapSelectionAsLink(url: string, selected: string): boolean {
return PASTE_LINK_URL_PATTERN.test(url)
&& !/\s/.test(url)
&& selected.trim().length > 0
&& !selected.includes('](');
}
@@ -0,0 +1,124 @@
/**
* The composer's send / queue / stop control.
*
* Which one is shown depends on whether a turn is running: idle sends, a busy
* session with content offers both queue (above) and stop, a busy session
* without content offers only stop.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { StopIcon } from '@/components/icons/StopIcon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
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;
};
export 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 = (
<button
type={isMobile ? 'button' : 'submit'}
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
onClick={(event) => {
if (!isMobile) {
return;
}
event.preventDefault();
onPrimaryAction();
}}
className={cn(
footerIconButtonClass,
canSend && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label={t('chat.chatInput.actions.sendMessageAria')}
>
<Icon name="send-plane-2" className={cn(sendIconSizeClass)} />
</button>
);
if (!canAbort) {
return sendButton;
}
return (
<div className="relative">
{hasContent ? (
<button
type="button"
disabled={!currentSessionId}
onClick={(event) => {
if (isMobile) {
event.preventDefault();
}
onQueueMessage();
}}
className={cn(
footerIconButtonClass,
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
)}
aria-label={t('chat.chatInput.actions.queueMessageAria')}
>
<Icon name="send-plane-2" className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
) : null}
<button
type="button"
onClick={onAbort}
className={cn(
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label={t('chat.chatInput.actions.stopGeneratingAria')}
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
</div>
);
}, (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
));
@@ -0,0 +1,142 @@
/**
* Attachment and settings controls in the composer footer.
*
* Rendered twice on mobile once in the collapsed pill, once in the expanded
* footer so it stays a memoized component with an explicit comparator: a
* re-render of the whole composer must not tear down the dropdown while it is
* open.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
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;
};
export const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
const { t } = useI18n();
const {
isVSCode,
footerIconButtonClass,
iconSizeClass,
handlePickLocalFiles,
openIssuePicker,
openPrPicker,
onOpenSettings,
} = props;
return (
<div className="flex items-center gap-x-1.5">
<div className="relative inline-flex">
{props.onOpenMobileSheet ? (
<button
type="button"
className={footerIconButtonClass}
onClick={props.onOpenMobileSheet}
// Same guard as PermissionAutoAcceptButton: keep the tap
// from dismissing the keyboard. On Android's
// resizes-content viewport the keyboard-close relayout
// moves this button mid-tap and the click never lands.
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
}
}}
title={t('chat.chatInput.actions.addAttachment')}
aria-label={t('chat.chatInput.actions.addAttachment')}
>
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
) : isVSCode ? (
<button
type="button"
className={footerIconButtonClass}
onClick={handlePickLocalFiles}
title={t('chat.chatInput.actions.attachFiles')}
aria-label={t('chat.chatInput.actions.attachFiles')}
>
<Icon name="attachment-2" className={cn(iconSizeClass, 'text-current')} />
</button>
) : (
<DropdownMenu onOpenChange={props.onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={footerIconButtonClass}
title={t('chat.chatInput.actions.addAttachment')}
aria-label={t('chat.chatInput.actions.addAttachment')}
>
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
}}
>
<Icon name="attachment-2"/>
{t('chat.chatInput.actions.attachFiles')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openIssuePicker);
}}
>
<Icon name="github"/>
{t('chat.chatInput.actions.linkGithubIssue')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openPrPicker);
}}
>
<Icon name="git-pull-request"/>
{t('chat.chatInput.actions.linkGithubPr')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{onOpenSettings ? (
<button
type="button"
onClick={onOpenSettings}
className={footerIconButtonClass}
title={t('chat.chatInput.actions.modelAgentSettings')}
aria-label={t('chat.chatInput.actions.modelAgentSettings')}
>
<Icon name="ai-agent" className={cn(iconSizeClass, 'text-current')} />
</button>
) : null}
</div>
);
}, (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
));
@@ -0,0 +1,125 @@
/**
* The composer's autocomplete popups.
*
* Four pickers, one at a time: the command palette, the inline skill picker,
* the snippet picker and the file/agent mention picker. Which one is open is
* decided by the prompt language, not here.
*
* They are positioned differently depending on the composer's shape. In the
* normal composer each picker anchors itself to the composer edge, which its
* own styles handle. In desktop focus mode the composer fills the surface, so
* a picker pinned to its edge would sit far from the text there they are
* placed at the caret instead, which is what `overlayPosition` carries.
*/
import React from 'react';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '../../CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '../../FileMentionAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from '../../SkillAutocomplete';
import { SnippetAutocomplete, type SnippetAutocompleteHandle } from '../../SnippetAutocomplete';
import type { AutocompleteKind } from '../language/triggers';
export interface AutocompleteOverlayPosition {
top: number;
left: number;
place: 'above' | 'below';
maxHeight: number;
}
/** Widths each picker asks for when placed at the caret. */
const CARET_PLACED_WIDTH: Record<AutocompleteKind, number> = {
mention: 520,
command: 450,
snippet: 450,
skill: 360,
};
/**
* Caret placement, or undefined to let the picker anchor to the composer.
* Only focus mode uses the caret; everywhere else the composer is short
* enough that its edge is close to the text.
*/
function caretStyle(
kind: AutocompleteKind,
position: AutocompleteOverlayPosition | null,
): React.CSSProperties | undefined {
if (!position) return undefined;
return {
left: `${position.left}px`,
top: `${position.top}px`,
bottom: 'auto',
width: `min(${CARET_PLACED_WIDTH[kind]}px, calc(100% - ${position.left + 8}px))`,
maxHeight: `${position.maxHeight}px`,
transform: position.place === 'above' ? 'translateY(-100%)' : undefined,
};
}
export interface ComposerAutocompletePopupsProps {
/** Which picker is open, if any. */
open: AutocompleteKind | null;
query: string;
/** Caret placement in focus mode; null when the picker anchors itself. */
overlayPosition: AutocompleteOverlayPosition | null;
commandRef: React.RefObject<CommandAutocompleteHandle | null>;
skillRef: React.RefObject<SkillAutocompleteHandle | null>;
snippetRef: React.RefObject<SnippetAutocompleteHandle | null>;
mentionRef: React.RefObject<FileMentionHandle | null>;
onCommandSelect: (command: CommandInfo) => void;
onSkillSelect: (skillName: string) => void;
onSnippetSelect: (snippet: unknown, trigger: string) => void;
onFileSelect: (file: { name: string; path: string; relativePath?: string }) => void;
onAgentSelect: (agentName: string) => void;
onClose: () => void;
}
export function ComposerAutocompletePopups(props: ComposerAutocompletePopupsProps) {
const { open, query, overlayPosition, onClose } = props;
if (!open) return null;
const style = caretStyle(open, overlayPosition);
switch (open) {
case 'command':
return (
<CommandAutocomplete
ref={props.commandRef}
searchQuery={query}
onCommandSelect={props.onCommandSelect}
onClose={onClose}
style={style}
/>
);
case 'skill':
return (
<SkillAutocomplete
ref={props.skillRef}
searchQuery={query}
onSkillSelect={props.onSkillSelect}
onClose={onClose}
style={style}
/>
);
case 'snippet':
return (
<SnippetAutocomplete
ref={props.snippetRef}
searchQuery={query}
onSnippetSelect={props.onSnippetSelect}
onClose={onClose}
style={style}
/>
);
case 'mention':
return (
<FileMentionAutocomplete
ref={props.mentionRef}
searchQuery={query}
onFileSelect={props.onFileSelect}
onAgentSelect={props.onAgentSelect}
onClose={onClose}
style={style}
/>
);
}
}
@@ -0,0 +1,137 @@
/**
* Context chips above the composer.
*
* Each chip stands for context that will be attached to the next message but
* is not part of its text: review comments left in a diff, captured dev-server
* logs, preview annotations, terminal selections. They are shown so the user
* knows what is riding along and can drop any of it before sending.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import type { Theme } from '@/types/theme';
export interface ComposerContextChipsProps {
/** Terminal selections, which show their own label and line range. */
terminalDrafts: readonly InlineCommentDraft[];
reviewCount: number;
previewConsoleCount: number;
previewAnnotationCount: number;
draftTarget: InlineCommentDraftTarget | null;
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
onRemoveReviewDrafts: () => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation') => void;
colors: Theme['colors'];
}
/** A chip showing how many items of one kind are attached, with a clear action. */
function CountChip(props: {
label: string;
count: number;
removeLabel: string;
onRemove: () => void;
colors: Theme['colors'];
}) {
return (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: props.colors?.surface?.elevated,
borderColor: props.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
{props.count}
</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
style={{ minHeight: 0, minWidth: 0 }}
onClick={props.onRemove}
aria-label={props.removeLabel}
title={props.removeLabel}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
);
}
export function ComposerContextChips(props: ComposerContextChipsProps) {
const { t } = useI18n();
const {
terminalDrafts,
reviewCount,
previewConsoleCount,
previewAnnotationCount,
draftTarget,
onRemoveDraft,
onRemoveReviewDrafts,
onRemovePreviewDrafts,
colors,
} = props;
return (
<div className="flex flex-wrap items-center gap-2 pb-2">
{terminalDrafts.map((draft) => (
<div
key={draft.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1"
title={draft.code}
>
<Icon name="terminal" className="h-3.5 w-3.5" />
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
})}
</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
aria-label={t('chat.chatInput.terminalContextRemove')}
title={t('chat.chatInput.terminalContextRemove')}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
))}
{reviewCount > 0 ? (
<CountChip
label={t('chat.chatInput.reviewComments')}
count={reviewCount}
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
onRemove={onRemoveReviewDrafts}
colors={colors}
/>
) : null}
{previewConsoleCount > 0 ? (
<CountChip
label={t('chat.chatInput.devServerLogs')}
count={previewConsoleCount}
removeLabel={t('chat.chatInput.devServerLogsRemove')}
onRemove={() => onRemovePreviewDrafts('preview-console')}
colors={colors}
/>
) : null}
{previewAnnotationCount > 0 ? (
<CountChip
label={t('chat.chatInput.previewAnnotations')}
count={previewAnnotationCount}
removeLabel={t('chat.chatInput.previewContextRemove')}
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
colors={colors}
/>
) : null}
</div>
);
}
@@ -0,0 +1,265 @@
/**
* The composer's footer row.
*
* Desktop lays it out as attachments and toggles on the left, model controls
* and send on the right. Mobile keeps everything on one line and swaps the
* model controls for the compact buttons above the text, because the footer
* has to stay reachable with one thumb.
*
* The dictation component is rendered here on desktop only: on mobile it lives
* at the composer wrapper level so a recording started from the collapsed pill
* survives the expand.
*/
import React from 'react';
import { SessionGoalButton, SessionGoalObjectiveCounter } from '@/components/chat/SessionGoalButton';
import { ComposerDictation } from '@/components/dictation/ComposerDictation';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { ModelControls } from '../../ModelControls';
import { MobileSessionPanelTrigger } from '../../MobileSessionStatusBar';
import { ComposerActionButtons } from './ComposerActionButtons';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
import { FocusModeButton } from './FocusModeButton';
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
export interface ComposerFooterProps {
isMobile: boolean;
isVSCode: boolean;
sessionId: string | null;
directory?: string;
newSessionDraftOpen: boolean;
messageLength: number;
radius: string;
footerPaddingClass: string;
footerGapClass: string;
footerIconButtonClass: string;
iconSizeClass: string;
sendIconSizeClass: string;
stopIconSizeClass: string;
canSend: boolean;
canAbort: boolean;
hasContent: boolean;
isExpandedInput: boolean;
permissionAutoAcceptEnabled: boolean;
isPermissionAutoAcceptInteractive: boolean;
dictationActive: boolean;
onOpenSettings?: () => void;
onPickLocalFiles: () => void;
onOpenIssuePicker: () => void;
onOpenPrPicker: () => void;
onOpenAttachSheet: () => void;
onToggleExpandedInput: () => void;
onTogglePermissionAutoAccept: () => void;
onPrimaryAction: () => void;
onQueueMessage: () => void;
onAbort: () => void;
onStartDictation: () => void;
onDictationInsert: (text: string) => void;
onDictationInsertAndSend: (text: string) => void;
onDictationContentHeightChange: (height: number | null) => void;
}
export function ComposerFooter(props: ComposerFooterProps) {
const { t } = useI18n();
const {
isMobile,
isVSCode,
sessionId: currentSessionId,
directory,
newSessionDraftOpen,
messageLength,
radius: chatInputRadius,
footerPaddingClass,
footerGapClass,
footerIconButtonClass,
iconSizeClass,
sendIconSizeClass,
stopIconSizeClass,
canSend,
canAbort,
hasContent,
isExpandedInput,
permissionAutoAcceptEnabled,
isPermissionAutoAcceptInteractive,
dictationActive,
onOpenSettings,
onPickLocalFiles,
onOpenIssuePicker,
onOpenPrPicker,
onOpenAttachSheet,
onToggleExpandedInput,
onTogglePermissionAutoAccept,
onPrimaryAction,
onQueueMessage,
onAbort,
onStartDictation,
onDictationInsert,
onDictationInsertAndSend,
onDictationContentHeightChange,
} = props;
return (
<div
className={cn(
'bg-transparent flex-shrink-0',
footerPaddingClass,
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
)}
style={{
borderBottomLeftRadius: chatInputRadius,
borderBottomRightRadius: chatInputRadius,
}}
data-chat-input-footer="true"
>
{isMobile ? (
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
handlePickLocalFiles={onPickLocalFiles}
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet}
/>
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isInteractive={isPermissionAutoAcceptInteractive}
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
/>
<SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<SessionGoalObjectiveCounter length={messageLength} />
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 flex-shrink-0">
<button
type="button"
className={footerIconButtonClass}
// Keep the soft keyboard open (same guard as
// PermissionAutoAcceptButton); the recording
// engine lives in the wrapper-level
// ComposerDictation instance.
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
}
}}
onClick={onStartDictation}
disabled={dictationActive}
title={t('chat.dictation.start')}
aria-label={t('chat.dictation.start')}
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass}
canSend={canSend}
canAbort={canAbort}
hasContent={hasContent}
currentSessionId={currentSessionId}
newSessionDraftOpen={newSessionDraftOpen}
onPrimaryAction={onPrimaryAction}
onQueueMessage={onQueueMessage}
onAbort={onAbort}
/>
</div>
</div>
</div>
</>
) : (
<>
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
handlePickLocalFiles={onPickLocalFiles}
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
onOpenSettings={onOpenSettings}
/>
<FocusModeButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isExpandedInput={isExpandedInput}
onToggle={onToggleExpandedInput}
/>
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isInteractive={isPermissionAutoAcceptInteractive}
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
withTooltip
/>
<SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
withTooltip
/>
<SessionGoalObjectiveCounter length={messageLength} />
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
<MemoComposerDictation
radius={chatInputRadius}
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
footerPaddingClass={footerPaddingClass}
iconSizeClass={iconSizeClass}
sendIconSizeClass={sendIconSizeClass}
onInsert={onDictationInsert}
onInsertAndSend={onDictationInsertAndSend}
onContentHeightChange={onDictationContentHeightChange}
/>
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass}
canSend={canSend}
canAbort={canAbort}
hasContent={hasContent}
currentSessionId={currentSessionId}
newSessionDraftOpen={newSessionDraftOpen}
onPrimaryAction={onPrimaryAction}
onQueueMessage={onQueueMessage}
onAbort={onAbort}
/>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,360 @@
/**
* Where a new session will run: the project and the directory within it.
*
* Desktop uses inline selects; mobile uses bottom sheets, because a native
* select over a keyboard-resized viewport is unusable. Both render the same
* options from the same hook, and both offer creating a worktree inline so the
* user does not have to leave the draft to make one.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths';
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
export interface BranchOption {
value: string;
label: string;
pending?: boolean;
}
export interface DraftTargetProps {
projects: readonly DraftTargetProject[];
selectedProject: DraftTargetProject;
selectedDirectory: string | null;
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
showBranchSelector: boolean;
onProjectChange: (projectId: string) => void;
onDirectoryChange: (directory: string) => void;
theme: Theme;
}
const getProjectIconColor = (projectColor?: string | null): string | undefined =>
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: theme.metadata.variant,
iconColor: theme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : fallbackIcon}
<span className="truncate">{getProjectDisplayLabel(project)}</span>
</span>
);
}
/** Desktop: inline project and branch selects. */
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const {
projects,
selectedProject,
selectedDirectory,
selectedBranchLabel,
selectedBranchIsKnown,
projectRootBranchOption,
worktreeBranchOptions,
branchItems,
showBranchSelector,
onProjectChange,
onDirectoryChange,
theme,
} = props;
return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
onValueChange={onProjectChange}
>
<SelectTrigger
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
</SelectItem>
))}
</SelectContent>
</Select>
{showBranchSelector ? (
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
onValueChange={onDirectoryChange}
>
<SelectTrigger
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
</SelectGroup>
) : null}
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
<button
type="button"
className="text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); void createWorktreeDraft(); }}
>
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
{option.pending ? '⏳ ' : ''}{option.label}
</SelectItem>
))}
</SelectGroup>
{selectedDirectory && !selectedBranchIsKnown ? (
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
{selectedBranchLabel}
</SelectItem>
) : null}
</SelectContent>
</Select>
) : null}
</div>
);
}
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
) : null}
</div>
);
}
/**
* Mobile: the project and branch sheets. Bottom sheets rather than selects
* because a native select over a keyboard-resized viewport is unusable.
*/
export function MobileDraftTargetSheets(
props: DraftTargetProps & {
openPicker: 'project' | 'branch' | null;
onOpenPickerChange: (picker: 'project' | 'branch' | null) => void;
query: string;
onQueryChange: (query: string) => void;
},
) {
const { t } = useI18n();
const {
projects,
selectedProject,
selectedDirectory,
selectedBranchLabel,
selectedBranchIsKnown,
projectRootBranchOption,
worktreeBranchOptions,
branchItems,
onProjectChange,
onDirectoryChange,
openPicker,
onOpenPickerChange,
query,
onQueryChange,
theme,
} = props;
return (
<>
<MobileOverlayPanel
open={openPicker === 'project'}
title={t('chat.chatInput.draftPicker.projectTitle')}
onClose={() => onOpenPickerChange(null)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
className="h-9"
/>
<div className="flex flex-col">
{projects
.filter((project) => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|| project.path.toLowerCase().includes(needle);
})
.map((project) => (
<button
key={project.id}
type="button"
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick={() => {
onProjectChange(project.id);
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
</button>
))}
</div>
</div>
</MobileOverlayPanel>
<MobileOverlayPanel
open={openPicker === 'branch'}
title={t('chat.chatInput.branch')}
onClose={() => onOpenPickerChange(null)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t('chat.chatInput.draftPicker.searchBranches')}
className="h-9"
/>
<div className="flex flex-col">
{(() => {
const needle = query.trim().toLowerCase();
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
const selectedValue = selectedDirectory
?? branchItems[0]?.value
?? normalizePath(selectedProject.path)
?? '';
const renderRow = (value: string, label: React.ReactNode, key?: string) => (
<button
key={key ?? value}
type="button"
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick={() => {
onDirectoryChange(value);
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{value === selectedValue ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
</button>
);
return (
<>
{projectRootBranchOption && matches(projectRootBranchOption.label) ? (
<>
<div className="px-2 pb-1 pt-1.5 text-muted-foreground typography-meta">
{t('chat.chatInput.projectRoot')}
</div>
{renderRow(projectRootBranchOption.value, projectRootBranchOption.label)}
</>
) : null}
<div className="flex items-center justify-between px-2 pb-1 pt-2">
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
<button
type="button"
className="cursor-pointer text-muted-foreground typography-meta hover:text-foreground"
onClick={() => {
onOpenPickerChange(null);
void createWorktreeDraft();
}}
>
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions
.filter((option) => matches(option.label))
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
: null}
</>
);
})()}
</div>
</div>
</MobileOverlayPanel>
</>
);
}
@@ -0,0 +1,53 @@
/** Expands the composer to fill the surface (desktop focus mode). */
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn, isMacOS } from '@/lib/utils';
type FocusModeButtonProps = {
footerIconButtonClass: string;
iconSizeClass: string;
isExpandedInput: boolean;
onToggle: () => void;
};
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
footerIconButtonClass,
'rounded-md',
isExpandedInput
? 'text-primary'
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={onToggle}
aria-label={t('chat.chatInput.focusMode.toggleAria')}
aria-pressed={isExpandedInput}
>
<Icon name="fullscreen" className={cn(iconSizeClass)} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center">
<span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60">
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
</div>
</TooltipContent>
</Tooltip>
);
});
@@ -0,0 +1,96 @@
/**
* The linked GitHub issue or pull request shown above the composer.
*
* Linking one attaches its body and for a PR its diff as context on the
* next send. The row exists so that context is visible and dismissible rather
* than silently riding along, and clicking it reopens the picker to swap the
* reference.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
export interface LinkedReferenceRowProps {
/** Shown before the title: `#12` for an issue, `PR #12` for a pull request. */
numberLabel: React.ReactNode;
title: string;
url: string;
author?: { login: string; avatarUrl?: string };
/** A PR also shows its branches. */
branches?: { head: string; base: string };
openInBrowserLabel: string;
removeLabel: string;
onReopenPicker: () => void;
onRemove: () => void;
}
export function LinkedReferenceRow(props: LinkedReferenceRowProps) {
const { t } = useI18n();
const {
numberLabel,
title,
url,
author,
branches,
openInBrowserLabel,
removeLabel,
onReopenPicker,
onRemove,
} = props;
return (
<div className="pb-2 w-full px-1">
<div className="flex w-full items-center gap-1.5 text-sm h-5 px-1">
<button
type="button"
onClick={onReopenPicker}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
>
{author?.avatarUrl ? (
<img
src={author.avatarUrl}
alt={author.login}
className="h-5 w-5 rounded-full flex-shrink-0"
/>
) : null}
<span className="text-muted-foreground flex-shrink-0">
{numberLabel}
{author ? (
<span className="ml-1">
{t('chat.chatInput.linked.byAuthor', { author: author.login })}
</span>
) : null}
</span>
<span className="text-foreground truncate">{title}</span>
{branches ? (
<span className="text-muted-foreground flex-shrink-0 typography-meta">
{branches.head} {branches.base}
</span>
) : null}
</button>
<span className="flex items-center gap-0.5 flex-shrink-0">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label={openInBrowserLabel}
>
<Icon name="external-link" className="h-4 w-4 text-muted-foreground" />
</a>
<button
type="button"
onClick={onRemove}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label={removeLabel}
title={removeLabel}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</button>
</span>
</div>
</div>
);
}
@@ -0,0 +1,152 @@
/**
* The collapsed mobile composer.
*
* With the keyboard down the composer is a pill: attachments, a one-line
* preview of the draft, and a mic, with a round new-session button beside it.
* Tapping anywhere in it expands the real composer and raises the keyboard in
* the same gesture which is why the expand handler must run synchronously
* from the tap rather than from an effect.
*
* The new-session button collapses away once a draft is already open, letting
* the pill grow into its place.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { Theme } from '@/types/theme';
import { MobileSessionPanelTrigger } from '../../MobileSessionStatusBar';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
export interface MobilePillComposerProps {
message: string;
sessionId: string | null;
directory?: string;
newSessionDraftOpen: boolean;
hasContent: boolean;
isVSCode: boolean;
footerIconButtonClass: string;
iconSizeClass: string;
theme: Theme;
onExpand: () => void;
onApplySuggestion: (text: string) => void;
onNewSession: () => void;
onPickLocalFiles: () => void;
onOpenIssuePicker: () => void;
onOpenPrPicker: () => void;
onOpenAttachSheet: () => void;
onStartDictation: () => void;
}
export function MobilePillComposer(props: MobilePillComposerProps) {
const { t } = useI18n();
const {
message,
sessionId: currentSessionId,
directory,
newSessionDraftOpen,
hasContent,
isVSCode,
footerIconButtonClass,
iconSizeClass,
theme: currentTheme,
onExpand,
onApplySuggestion,
onNewSession,
onPickLocalFiles,
onOpenIssuePicker,
onOpenPrPicker,
onOpenAttachSheet,
onStartDictation,
} = props;
return (
<div className="flex flex-col">
<SessionGoalRow
sessionId={currentSessionId}
directory={directory}
className="mb-1.5"
/>
<SessionSuggestionChip
sessionId={currentSessionId}
directory={directory}
hidden={hasContent || newSessionDraftOpen}
onApply={onApplySuggestion}
className="mb-1.5"
/>
<div className="flex items-center gap-2">
<div
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
>
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
handlePickLocalFiles={onPickLocalFiles}
openIssuePicker={onOpenIssuePicker}
openPrPicker={onOpenPrPicker}
onOpenMobileSheet={onOpenAttachSheet}
/>
<button
type="button"
className="flex h-full min-w-0 flex-1 cursor-text items-center px-1.5 text-left"
onClick={onExpand}
>
<span
className={cn(
'truncate typography-ui-label',
message.trim() ? 'text-foreground' : 'text-muted-foreground',
)}
>
{message.trim()
? message
: currentSessionId || newSessionDraftOpen
? t('chat.chatInput.placeholder.chatCompact')
: t('chat.chatInput.placeholder.selectSession')}
</span>
</button>
<button
type="button"
className={footerIconButtonClass}
// Starts recording in place; the composer morphs into the
// voice variant once dictation actually goes live.
onClick={onStartDictation}
title={t('chat.dictation.start')}
aria-label={t('chat.dictation.start')}
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
{/* New-session button: fades/shrinks away when the draft is
already open, letting the pill expand into its place. */}
<div
className={cn(
'flex-shrink-0 transition-all duration-200 ease-out',
newSessionDraftOpen ? 'w-0 opacity-0 overflow-hidden' : 'w-11 opacity-100',
)}
>
<button
type="button"
className="flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border/80 text-foreground shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
onClick={onNewSession}
disabled={newSessionDraftOpen}
title={t('mobile.sessions.newChat')}
aria-label={t('mobile.sessions.newChat')}
>
<Icon name="add" className="h-5 w-5 text-current" />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
/**
* Toggles whether tool permissions are auto-accepted for this session.
*
* The pointer guards keep a tap from dismissing the mobile keyboard: on
* Android's resizes-content viewport the keyboard-close relayout moves this
* button mid-tap and the click never lands.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type PermissionAutoAcceptButtonProps = {
footerIconButtonClass: string;
iconSizeClass: string;
isInteractive: boolean;
permissionAutoAcceptEnabled: boolean;
handlePermissionAutoAcceptToggle: () => void;
withTooltip?: boolean;
};
export 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 = (
<button
type="button"
onClick={handlePermissionAutoAcceptToggle}
className={cn(
footerIconButtonClass,
'rounded-md hover:bg-transparent',
!isInteractive && 'opacity-30',
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
aria-pressed={permissionAutoAcceptEnabled}
aria-label={ariaLabel}
title={ariaLabel}
>
{permissionAutoAcceptEnabled ? (
<Icon name="shield-check" className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
) : (
<Icon name="shield-user" className={cn(iconSizeClass)} />
)}
</button>
);
if (!withTooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{tooltipLabel}
</TooltipContent>
</Tooltip>
);
});
@@ -0,0 +1,181 @@
/**
* The reverted-messages dock.
*
* After a revert the messages that were undone are not thrown away: they stay
* listed here so the user can put one back, or fork a new session from it.
* Restoring the newest reverted message is an un-revert of everything, which
* is why it routes through handleSlashRedo rather than reverting forward.
*/
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2/client';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import { isSyntheticPart } from '@/lib/messages/synthetic';
import { cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync } from '@/sync/sync-context';
import {
EMPTY_REVERTED_MESSAGE_DOCK_STATE,
buildRevertedMessageDockState,
type RevertedMessageDockState,
} from '../../revertedMessageDockState';
/**
* A one-line preview of a reverted message: its text parts joined and
* collapsed to a single line, falling back to an attached filename and then to
* a caller-supplied placeholder.
*/
const getRevertedPreview = (parts: Part[], fallback: string): string => {
const text = parts
.filter((part) => part.type === 'text' && !isSyntheticPart(part))
.map((part) => {
const record = part as Record<string, unknown>;
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;
};
type RevertedMessageDockProps = {
sessionId: string | null;
directory?: string;
};
export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = 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<string | null>(null);
const [forkingId, setForkingId] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState(true);
const revertedStateRef = React.useRef<RevertedMessageDockState>(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 (
<div className="pb-2 w-full px-1">
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--interactive-hover)] transition-colors"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
>
<span className="typography-ui-label font-medium text-foreground flex-shrink-0">
{t('chat.revertPopover.title')} messages {items.length}
</span>
<Icon
name="arrow-down-s"
className={cn("ml-auto h-4 w-4 text-muted-foreground transition-transform", !collapsed && "rotate-180")}
aria-hidden="true"
/>
</button>
{!collapsed && (
<div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="flex min-w-0 items-center gap-2 py-1">
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
{item.text}
</span>
<Button
type="button"
variant="secondary"
size="xs"
disabled={Boolean(restoringId || forkingId)}
onClick={() => { void handleFork(item.id); }}
>
{forkingId === item.id ? (
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
) : (
<Icon name="git-branch" className="h-3 w-3" aria-hidden="true" />
)}
{t('chat.revertPopover.fork')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
disabled={Boolean(restoringId || forkingId)}
onClick={() => { void handleRestore(item.id); }}
>
{restoringId === item.id ? (
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
) : (
<Icon name="arrow-go-forward" className="h-3 w-3" aria-hidden="true" />
)}
{t('chat.revertPopover.restore')}
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
});
RevertedMessageDock.displayName = 'RevertedMessageDock';
@@ -1,20 +1,21 @@
/**
* Lightweight markdown tokenizer for the chat composer's highlight overlay.
* Markdown tokenizer for the chat composer.
*
* The composer renders a transparent <textarea> on top of a mirror <div>
* (see ChatInput.tsx). The div paints the colored text the user sees while the
* textarea owns the caret and selection. For the two layers to stay aligned the
* overlay may only use styles that DO NOT change glyph advance width:
* color, text-decoration and background. Font weight / style / family / size
* would shift the text and make the highlight drift from the caret, so they are
* intentionally avoided here.
* The composer paints a "source mode" look similar to GitHub's comment editor:
* the constructs stay visible as text while their syntax punctuation dims and
* their content takes on the shape it will have when rendered.
*
* As a result we highlight the high-signal, low-false-positive markdown
* constructs (code, links, headings, blockquotes, list markers) and dim their
* syntax punctuation a "source mode" look similar to GitHub's comment editor.
* Emphasis (*bold* / _italic_) is deliberately not colored: it can only be
* expressed through font weight (which we cannot use) and its delimiters clash
* with ordinary prose (`2 * 3`, `foo_bar`).
* The rule that shapes this file is which constructs are worth recognizing at
* all. Emphasis delimiters collide with ordinary prose `2 * 3`, `foo_bar`
* so they are matched only in positions where the collision cannot occur,
* rather than wherever the character appears.
*
* (Until the editor moved to CodeMirror, highlighting also could not use any
* style that changes glyph advance width: the composer was a transparent
* textarea over a mirror div, and a bold span would slide the mirror out from
* under the caret. That constraint is gone emphasis is real weight and
* slant now but `className` values still have to be safe to apply to a
* decoration, so avoid font-family and font-size.)
*/
type HighlightStyle =
@@ -25,7 +26,11 @@ type HighlightStyle =
| 'linkUrl'
| 'heading'
| 'blockquote'
| 'listMarker';
| 'listMarker'
| 'strong'
| 'emphasis'
| 'attention'
| 'path';
type MentionKind = 'file' | 'agent';
@@ -36,8 +41,9 @@ export interface HighlightRange {
/**
* Optional explicit class, used by syntax highlighting where the style is
* resolved dynamically (per language token) rather than from a fixed enum.
* When set it overrides STYLE_CLASS[style]. Must remain metric-safe
* (color / decoration / background only).
* When set it overrides STYLE_CLASS[style]. Keep to properties that are
* safe on an inline decoration: colour, background, decoration, weight and
* slant are fine; font-family and font-size are not.
*/
className?: string;
/** Optional explicit priority; falls back to STYLE_PRIORITY[style]. */
@@ -55,6 +61,17 @@ export interface HighlightPart {
className: string;
}
/**
* A resolved, non-overlapping stretch of text carrying exactly one class.
* Offsets rather than text, so a decoration-based renderer (CodeMirror) can
* consume the same resolution the mirror overlay does.
*/
export interface HighlightSegment {
start: number;
end: number;
className: string;
}
type AnyStyle = HighlightRange['style'];
// Higher priority wins when ranges overlap on a given segment.
@@ -65,15 +82,22 @@ const STYLE_PRIORITY: Record<AnyStyle, number> = {
mentionSnippet: 100,
code: 90,
codeFence: 90,
// A bare path is a visual aid, not a reference: an `@mention` covering the
// same span must keep its own colour.
path: 85,
link: 80,
linkUrl: 78,
heading: 70,
attention: 60,
// Emphasis is additive (see ADDITIVE_STYLES), so its priority never
// decides a segment; it is listed only to satisfy the table.
strong: 50,
emphasis: 50,
blockquote: 40,
listMarker: 35,
marker: 10,
};
// Color / decoration / background only — never anything that affects layout.
const STYLE_CLASS: Record<AnyStyle, string> = {
mentionFile: 'text-[var(--status-info)]',
mentionAgent: 'text-[var(--status-success)]',
@@ -81,16 +105,83 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
// of thing.
path: 'text-[var(--status-info)]',
link: 'text-[var(--status-info)] underline',
linkUrl: 'text-muted-foreground',
heading: 'text-[var(--syntax-keyword)]',
attention: 'font-semibold text-[var(--status-warning)]',
blockquote: 'text-muted-foreground',
listMarker: 'text-[var(--syntax-keyword)]',
marker: 'text-muted-foreground',
// Emphasis carries weight and slant only, never colour: it composes onto
// whatever the surrounding construct already painted.
strong: 'font-semibold',
emphasis: 'italic',
};
/**
* Styles that describe *how* text is set rather than what it is, and so add to
* the winning style instead of competing with it. A bold run inside a heading
* has to stay heading-coloured and become bold; picking one would lose the
* other, because a segment carries a single class string.
*/
const ADDITIVE_STYLES = new Set<AnyStyle>(['strong', 'emphasis']);
const DEFAULT_CLASS = 'text-foreground';
/**
* A delimiter run only opens emphasis when it is preceded by whitespace or
* punctuation and followed by content. `2 * 3` fails on the trailing space,
* and `foo_bar` fails on the preceding letter which is the whole reason
* emphasis is matched positionally rather than by character.
*/
function opensEmphasis(segment: string, index: number, runLength: number): boolean {
const before = index > 0 ? segment[index - 1] : '';
const after = segment[index + runLength];
if (!after || /\s/.test(after)) return false;
// Underscores additionally refuse to open mid-word, so identifiers survive.
if (segment[index] === '_' && /[\w]/.test(before)) return false;
return before === '' || !/[\w]/.test(before) || segment[index] === '*';
}
/** The closing run must hug its content and end the span at a boundary. */
function closesEmphasis(segment: string, index: number, runLength: number): boolean {
const before = segment[index - 1];
const after = segment[index + runLength];
if (!before || /\s/.test(before)) return false;
if (segment[index] === '_' && after && /[\w]/.test(after)) return false;
return true;
}
/**
* Find the emphasis span opening at `index`, or null. Returns the offset just
* past the closing delimiter.
*/
function matchEmphasis(segment: string, index: number): { end: number; runLength: number } | null {
const char = segment[index];
const run = /^(\*{1,3}|_{1,3})/.exec(segment.slice(index))?.[1] ?? '';
if (!run || !opensEmphasis(segment, index, run.length)) return null;
let search = index + run.length;
while (search < segment.length) {
const closeIndex = segment.indexOf(run, search);
if (closeIndex === -1) return null;
// A longer run than we opened with belongs to a different span.
if (segment[closeIndex + run.length] === char) {
search = closeIndex + run.length + 1;
continue;
}
if (closesEmphasis(segment, closeIndex, run.length)) {
return { end: closeIndex + run.length, runLength: run.length };
}
search = closeIndex + run.length;
}
return null;
}
/**
* Scan a single line (or the content portion of a block construct) for inline
* markdown spans and push their ranges. `base` is the absolute offset of
@@ -137,6 +228,34 @@ function scanInline(segment: string, base: number, out: HighlightRange[]): void
}
}
// Emphasis: **strong**, *emphasis*, ***both at once***, and the
// underscore spellings. Strong and emphasis are additive styles, so a
// triple run simply emits both ranges over the same content and they
// compose into bold italic.
if (ch === '*' || ch === '_') {
const match = matchEmphasis(segment, i);
if (match) {
const contentStart = base + i + match.runLength;
const contentEnd = base + match.end - match.runLength;
out.push({ start: base + i, end: contentStart, style: 'marker' });
if (match.runLength >= 2) {
out.push({ start: contentStart, end: contentEnd, style: 'strong' });
}
if (match.runLength !== 2) {
out.push({ start: contentStart, end: contentEnd, style: 'emphasis' });
}
out.push({ start: contentEnd, end: base + match.end, style: 'marker' });
// Scan the content too, so `**bold `code`**` keeps both.
scanInline(
segment.slice(i + match.runLength, match.end - match.runLength),
contentStart,
out,
);
i = match.end;
continue;
}
}
i += 1;
}
}
@@ -203,6 +322,22 @@ export function tokenizeMarkdown(text: string): HighlightRange[] {
continue;
}
// `!!! something important` — an attention line. Not markdown, but a
// convention people already type; three marks so an ordinary emphatic
// sentence ending in `!!` is not swallowed.
const attention = /^(\s*)(!!!)(\s+)/.exec(line);
if (attention) {
const markerStart = lineStart + attention[1].length;
const markerEnd = markerStart + attention[2].length;
ranges.push({ start: markerStart, end: markerEnd, style: 'marker' });
const contentStart = markerEnd + attention[3].length;
if (lineEnd > contentStart) {
ranges.push({ start: contentStart, end: lineEnd, style: 'attention' });
scanInline(line.slice(contentStart - lineStart), contentStart, ranges);
}
continue;
}
const heading = /^(\s*)(#{1,6})(\s+)/.exec(line);
if (heading) {
const markerStart = lineStart + heading[1].length;
@@ -246,16 +381,19 @@ export function tokenizeMarkdown(text: string): HighlightRange[] {
}
/**
* Split `text` into styled parts from a set of (possibly overlapping) ranges.
* Each output part carries a single className; adjacent parts that share a
* className are coalesced. Returns null when there is nothing to highlight so
* callers can skip the overlay entirely for plain text.
* Flatten a set of (possibly overlapping) ranges into non-overlapping segments,
* resolving each stretch to the highest-priority range covering it. This is the
* shared resolution step: the mirror overlay turns segments into spans,
* CodeMirror turns them into mark decorations, and both agree on what wins.
*
* Segments cover the whole text, including unstyled stretches, so callers can
* reconstruct the input exactly.
*/
export function buildHighlightParts(
export function resolveHighlightSegments(
text: string,
ranges: HighlightRange[],
): HighlightPart[] | null {
if (!text || ranges.length === 0) return null;
): HighlightSegment[] {
if (!text || ranges.length === 0) return [];
const len = text.length;
const bounds = new Set<number>([0, len]);
@@ -276,7 +414,7 @@ export function buildHighlightParts(
.filter((item) => item.range.end > item.range.start)
.sort((a, b) => a.range.start - b.range.start);
const parts: HighlightPart[] = [];
const segments: HighlightSegment[] = [];
const active: Array<{ range: HighlightRange; index: number }> = [];
let nextRange = 0;
@@ -296,7 +434,15 @@ export function buildHighlightParts(
let bestRange: HighlightRange | null = null;
let bestPriority = -1;
let bestIndex = Infinity;
// Additive styles do not compete; they are appended to whatever wins.
const additive: string[] = [];
for (const { range, index } of active) {
if (ADDITIVE_STYLES.has(range.style)) {
const extra = range.className ?? STYLE_CLASS[range.style];
if (!additive.includes(extra)) additive.push(extra);
continue;
}
const priority = range.priority ?? STYLE_PRIORITY[range.style];
if (priority > bestPriority || (priority === bestPriority && index < bestIndex)) {
bestPriority = priority;
@@ -305,21 +451,46 @@ export function buildHighlightParts(
}
}
const className = bestRange
const baseClass = bestRange
? (bestRange.className ?? STYLE_CLASS[bestRange.style])
: DEFAULT_CLASS;
const segText = text.slice(segStart, segEnd);
const last = parts[parts.length - 1];
if (last && last.className === className) {
last.text += segText;
const className = additive.length > 0
? [baseClass, ...additive].join(' ')
: baseClass;
// Coalesce here rather than in each renderer: fewer spans in the
// overlay and fewer decorations in the editor.
const last = segments[segments.length - 1];
if (last && last.className === className && last.end === segStart) {
last.end = segEnd;
} else {
parts.push({ text: segText, className });
segments.push({ start: segStart, end: segEnd, className });
}
}
return parts.length > 0 ? parts : null;
return segments;
}
/**
* Split `text` into styled parts for the mirror overlay. Returns null when
* there is nothing to highlight so callers can skip the overlay entirely for
* plain text.
*/
export function buildHighlightParts(
text: string,
ranges: HighlightRange[],
): HighlightPart[] | null {
const segments = resolveHighlightSegments(text, ranges);
if (segments.length === 0) return null;
return segments.map((segment) => ({
text: text.slice(segment.start, segment.end),
className: segment.className,
}));
}
/** The class an unstyled stretch of composer text carries. */
export const DEFAULT_HIGHLIGHT_CLASS = DEFAULT_CLASS;
export function mentionRangesToHighlightRanges(mentions: MentionRange[]): HighlightRange[] {
return mentions.map((mention) => ({
start: mention.start,
+4 -2
View File
@@ -8,6 +8,8 @@ import type React from 'react';
* some WebKit-based environments where composition
* events can be ordered unexpectedly.
*/
export const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
export const isIMECompositionEvent = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
// CodeMirror keymaps hand out the native event; React handlers wrap it.
const native = 'nativeEvent' in e ? e.nativeEvent : e;
return native.isComposing || native.keyCode === 229;
};
+31 -6
View File
@@ -684,10 +684,31 @@
the final one. Hide it during the transition (+ UIKit's reposition lag,
see oc-kb-caret-hold timing in useNativeMobileChrome) and pop it back in. */
:root.oc-capacitor-app.oc-kb-caret-hold textarea,
:root.oc-capacitor-app.oc-kb-caret-hold input {
:root.oc-capacitor-app.oc-kb-caret-hold input,
:root.oc-capacitor-app.oc-kb-caret-hold [contenteditable] {
caret-color: transparent;
}
/* The composer is a CodeMirror editor, so it is neither a textarea nor an
input, and its caret is not the native one either: drawSelection() hides
that and paints a .cm-cursor element instead. Hiding caret-color alone
would leave that element riding across the screen. */
:root.oc-capacitor-app.oc-kb-caret-hold .cm-cursor,
:root.oc-capacitor-app.oc-kb-caret-hold .cm-dropCursor {
opacity: 0;
}
/* On touch devices the composer re-enables the NATIVE caret with `!important`
(composerNativeSelectionTheme: iOS colours its selection handles from the
caret colour, so the caret cannot stay transparent). That declaration beats
the plain [contenteditable] rule above, so the caret-hold needs its own
heavier rule for the composer !important plus more specificity than the
theme's `.cm-editor .cm-content` selector. */
:root.oc-capacitor-app.oc-kb-caret-hold .cm-editor .cm-content,
:root.oc-capacitor-app.oc-kb-caret-hold .cm-editor .cm-content .cm-line {
caret-color: transparent !important;
}
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
room above the home indicator), but that gap looks artificial sitting right above
the keyboard so tighten it while the keyboard is open. Snaps at the start of the
@@ -697,11 +718,15 @@
padding-bottom: 12px;
}
/* Draft starter chips leave the moment the keyboard starts rising
(oc-keyboard-open lands at keyboardWillShow) and return when it's gone
with the keyboard up there is only room for the draft title. Instant
show/hide (no squish animation); the title's own keyboard compensation
(.oc-draft-center below) carries the smooth motion. */
/* Draft starter chips leave when the full composer takes over with the
composer expanded (and hence the keyboard up) there is only room for the
draft title. oc-composer-expanded is set in the SAME frame as the pill swap
(see useMobileComposerShell), so the chips leave together with the morph;
the keyboard classes remain as fallbacks for keyboard-up states that do not
go through the pill (and for mobile browsers). Instant show/hide (no squish
animation); the title's own keyboard compensation (.oc-draft-center below)
carries the smooth motion. */
:root.oc-composer-expanded .oc-draft-starters,
:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters,
:root.oc-browser-keyboard-open .oc-draft-starters {
display: none;