refactor(ui): move the composer onto ChatComposerInput - #1865
Merged
Conversation
Astro-Han
marked this pull request as draft
August 2, 2026 03:12
Astro-Han
force-pushed
the
refactor/ui-composer-chat-input
branch
2 times, most recently
from
August 2, 2026 08:42
e1ce5ef to
1cdc555
Compare
Astro-Han
marked this pull request as ready for review
August 2, 2026 10:04
Swap the composer's hand-rolled textarea for Astryx ChatComposerInput. The `@` file and `/` Skill popups become SearchSource triggers, a picked file becomes an inline token, and the reference-sized paste path now runs through useChatPasteAsToken. Retires use-mention-popup.ts and composer-mention-popup.tsx; the draft and prompt-history hooks talk to the input through a ComposerTextPort instead of poking a textarea's value and caret, which also removes the manual auto-resize. Our key conventions move to the onKeyDown seam, which owns Enter outright (the built-in submit clears the editor even when a send is rejected) and therefore owns the IME guard too. Two seams close gaps the swap opens: a beforeinput handler inserts newlines as `\n` text nodes so Chrome's block splitting can't flatten a multi-line draft on send, and a paste-capture guard keeps a mid-composition paste from being consumed. Prompt history stays ours — the built-in recall is per-mount, unconditional, and has no deletion story, while ours is persisted, shared across surfaces, and clearable from Settings · 数据 — so the input mounts with hasHistory=false. E2E selectors move off `textarea` to a shared COMPOSER_INPUT constant.
The new composer CSS matched `[contenteditable]`, which also selects the token span's `contenteditable="false"`, so `width: 100%` stretched every inline token to the full line and broke the surrounding text onto its own rows. Scope the rules to the editable and cover the `@` journey — menu, inline token, and the path the backend receives — with a spec that pins the token's width against the line.
The ChatComposerInput port shipped three key-handling defects that no test could see, because the composer E2E suite was never run against it. Alt+Enter was destructive: ChatComposerInput's Enter branch exempts only Shift, then clears the editor before ChatComposer's submit guard decides nothing was sent — so the draft vanished. Both modifiers now insert the break themselves. The break itself was wrong at the end of a draft: a scripted Range insertion of "\n" landed at the root level, where Chrome collapses a trailing newline and the serializer never saw it. execCommand 'insertLineBreak' produces the <br> Astryx already serializes, and keeps the caret and the native undo stack intact. The beforeinput seam narrows to multi-line insertText (fill, dictation) and moves to the component root, so it also survives a disabled-at-mount composer. The IME guard sat in the onKeyDown we pass down, but ChatComposerInput runs its trigger menu first and that branch has no isComposing check — so Enter mid-composition could open a menu selection. The guard is now a native listener on the component root: React 19 delegates at the root container, so stopping propagation there takes the key away from every React handler at once, ours included. Also drop a paste handler that cancelled the browser's paste during a composition (it dropped file payloads outright), the 32-entry file-search cache that could hand one workspace's paths to another, the hasDraftText mirror of state we already derive, and a duplicate isDisabled that stacked opacity to 0.3. The disabled editor keeps its height again: the CSS matches the direct child, not any [contenteditable] descendant.
ChatComposerInput separates an inline token from the text after it with U+00A0, and its own backspace-eats-the-token check keys on that exact character — so the editor has to keep it. Nothing normalized it on the way out, so every message written after a mention reached the backend with a no-break space where the user typed an ordinary one: invisible in the transcript, and a character a path-splitting tool will not match. Normalize at the single send choke point, leaving the editor's DOM alone. The mention journey now asserts the sent text exactly rather than prefix-matching up to the token.
…the bytes Six independent reviews of the ChatComposerInput port turned up three defects the port introduced and three tests of mine that could not fail. The menu now follows the caret. Astryx recomputes the active trigger only from `input`, so an arrow key off the query left the menu open over a trigger no longer under the cursor, and the next Enter spliced a token in at the stale offset and swallowed the send — the draft became "看一下 <token> @agent" and nothing was sent. The retired popup tracked this with a `selectionchange` listener; this is that listener, replaying one input event so upstream re-derives the trigger from its own grammar rather than a copy of it. A restored draft puts the caret at the end again. Upstream only restores it when the update lands on a focused editor, and a session switch swaps the draft while the composer is blurred, so the next keystroke prepended. The textarea did this unconditionally on the same two paths; so does the port now. The file search implements `cancel()`, which upstream calls before every real search and on reset and which we simply never provided. A slow query can no longer land its results behind a newer one. The same seam replaces the 32-entry-turned-single-entry probe cache: upstream's per-keystroke `search('')` reads nothing but `instanceof Promise` and discards the value, so the probe is answered from a resolved constant and the bare-`@` listing is no longer served from a promise that outlived the files the user created while composing. Three assertions could not observe what they claimed. Playwright normalizes whitespace, so `getByText` matched a U+00A0 against a plain space and `toHaveText` matched a newline against a space: the NBSP fix and both Shift/Alt+Enter cases passed with their fix removed. They read raw `textContent` now. The empty-menu case asserted zero sends immediately after the Enter that would leak one, then hid the leak by sending the same text twice; it pins the total instead. Backspace-eats-the-staged-Skill, the draft round trip, the native-event shape of the IME guard, and the search cancellation were all covered by nothing. Every fix here is pinned by a test that fails without it, verified by mutation.
Astro-Han
force-pushed
the
refactor/ui-composer-chat-input
branch
from
August 2, 2026 10:08
ad87a6f to
585d3e8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refs #1861 (slice 3). Replaces the composer's hand-rolled
<textarea>stack with Astryx'sChatComposerInput, a contentEditable that already owns the trigger menu, inline tokens, prompt history, and paste-as-token. Four self-built modules retire with it —use-mention-popup.ts,composer-mention-popup.tsx, and the mention popup's CSS and contract tests.What the port buys, beyond deleting a parallel implementation:
@mention is a chip in the draft, not raw text the user can half-delete. It serializes to its path on send.useChatPasteAsToken. A reference-sized paste folds into an expandable chip instead of flooding the input. Hosts that stage quotes (the main composer) keep gettingQuoteRefdrawer chips; the quote companion, which wires noonPasteAsQuote, now gets the inline chip for free.@and/instead of our owndetectMentionTrigger.Skills selection, permission mode, voice capture, and drag-and-drop stay on the outer composer, unchanged. Each is covered by an existing E2E journey that still passes.
Defects found and fixed here
Six independent reviews of the port — four subagents plus Codex and Kimi K3 — turned up defects a reviewer reading the port alone would not see. Each of the following is fixed in this PR and pinned by a test verified to fail without its fix.
Alt+Enter destroyed the draft.
ChatComposerInput's Enter branch exempts only Shift, then clears the editor beforeChatComposer's submit guard decides nothing was sent. Both modifiers now insert the break themselves.A break at the end of a draft vanished. A scripted Range insertion of
\nlanded at the root level, where Chrome collapses a trailing newline.execCommand('insertLineBreak')produces the<br>Astryx serializes, and keeps the caret and the native undo stack intact.The IME guard was in the wrong place. It sat in the
onKeyDownwe pass down, butChatComposerInputruns its trigger menu first and that branch has noisComposingcheck. The guard is now a native listener on the component root: React 19 delegates at the root container, so stopping propagation there takes the key away from every React handler at once, ours included.The trigger menu did not follow the caret. Astryx recomputes the active trigger only from
input, so an arrow key off the query left the menu open over a trigger no longer under the cursor — and the next Enter spliced a token in at the stale offset and swallowed the send. Typing看一下 @agent, pressing ArrowLeft six times, then Enter produced看一下 <token> @agentand sent nothing. The retired popup tracked this with aselectionchangelistener; this is that listener, replaying one input event so upstream re-derives the trigger from its own grammar rather than from a copy of it.A restored draft lost the caret. Upstream restores the caret to the end only when a controlled update lands on a focused editor, and switching sessions swaps the draft while the composer is blurred — so the next keystroke prepended to the restored text. The textarea did this unconditionally on the same two paths (draft swap, history recall); so does the port now.
The file search never implemented
cancel().useTriggerMenucalls it before every real search and on reset, and we simply did not provide it, so a slow query could land its results behind a newer one. The same seam replaces the probe cache: upstream's per-keystrokesearch('')reads nothing butinstanceof Promiseand discards the value, so the probe is now answered from a resolved constant, and the bare-@listing is no longer served from a promise that outlived the files the user created while composing.A no-break space reached the backend.
insertTokenanchors a chip with U+00A0, and its own backspace-eats-the-token check keys on that exact codepoint — so the editor keeps it and only the wire is normalized.Verification
Full desktop E2E, unit, and static checks on the rebased branch:
The two risks named in the issue were verified directly:
Chinese IME + Enter. Covered by
send-message.spec.ts, which dispatches the composition withisComposing: falseon purpose — only the composition we track ourselves can stop that Enter, so a passing test cannot be crediting the component's own native check. Also exercised by hand with a real CJK IME, which Playwright cannot synthesize.E2E selectors assuming
<textarea>.COMPOSER_INPUTtargets the contentEditable, and assertions moved offtoHaveValue. Fourteen spec files touched.Three assertions could not observe what they claimed
Worth calling out, because the failure mode is not obvious: Playwright normalizes whitespace in every text matcher.
getByTextmatched a U+00A0 against a plain space, andtoHaveTextmatched a newline against a space. So the NBSP fix and both Shift/Alt+Enter cases passed with their production fix deleted, and the empty-menu case asserted zero sends immediately after the Enter that would leak one, then hid the leak by sending the same text twice. All four read rawtextContentor pin a message total now.Every fix in this PR was mutation-checked: the guard, branch, or listener was removed, the workspace and renderer rebuilt, and the suite re-run to confirm the test goes red.
Review focus
One capability is deliberately lost. The retired
detectMentionTriggerallowed a space inside an@query (@foo barnarrowed the search). Astryx'sfindActiveTriggertreats a space as the end of the query, so that no longer works. Adopting the upstream grammar rather than forking it is the point of the slice, so the boundaries we now depend on are pinned by a contract test — an upstream change that moves one fails there instead of in front of a user.One upstream gap remains, deliberately unfixed.
findActiveTriggeraccepts only' 'and'\n'as a trigger boundary, butinsertTokenanchors a chip with U+00A0 — so typing@immediately after a chip opens no menu until the user types a space. The boundary set is internal touseTriggerMenu; the fix is a one-line Astryx patch, and rewriting the character on our side would breakuseChatComposerTokens' backspace-eats-the-token check, which keys on that exact codepoint. Documented at the call site.Two narrower edges are accepted rather than fixed. An inline token degrades to plain text when a draft or history entry is restored, because the controlled value is a string — the wire form is identical, so this is cosmetic. And a clipboard carrying both files and text loses the text if it is pasted while the composer is disabled or streaming.
Out of scope