Skip to content

refactor(ui): move the composer onto ChatComposerInput - #1865

Merged
Astro-Han merged 5 commits into
mainfrom
refactor/ui-composer-chat-input
Aug 2, 2026
Merged

refactor(ui): move the composer onto ChatComposerInput#1865
Astro-Han merged 5 commits into
mainfrom
refactor/ui-composer-chat-input

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #1861 (slice 3). Replaces the composer's hand-rolled <textarea> stack with Astryx's ChatComposerInput, 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:

  • Inline tokens. A picked @ 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 getting QuoteRef drawer chips; the quote companion, which wires no onPasteAsQuote, now gets the inline chip for free.
  • One trigger grammar for @ and / instead of our own detectMentionTrigger.

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 before ChatComposer'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 \n landed 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 onKeyDown we pass down, but ChatComposerInput runs its trigger menu first and that branch has no isComposing check. 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> @agent and sent nothing. 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 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(). useTriggerMenu calls 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-keystroke search('') reads nothing but instanceof Promise and 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. insertToken anchors 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:

$ npx playwright test -c e2e/playwright.config.ts
  82 passed (46.8s)

$ npm test --workspace=@maka/ui
ℹ pass 238   ℹ fail 0

$ npm run typecheck && npm run lint && npm run format:check
(clean)

$ node scripts/check-dead-css.mjs --check
check-dead-css: no dead classes or tokens found ✓

The two risks named in the issue were verified directly:

Chinese IME + Enter. Covered by send-message.spec.ts, which dispatches the composition with isComposing: false on 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_INPUT targets the contentEditable, and assertions moved off toHaveValue. 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. getByText matched a U+00A0 against a plain space, and toHaveText matched 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 raw textContent or 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 detectMentionTrigger allowed a space inside an @ query (@foo bar narrowed the search). Astryx's findActiveTrigger treats 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. findActiveTrigger accepts only ' ' and '\n' as a trigger boundary, but insertToken anchors 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 to useTriggerMenu; the fix is a one-line Astryx patch, and rewriting the character on our side would break useChatComposerTokens' 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

  • Tokenized message bubbles — slice 4, which depends on this landing.
  • User-bubble CSS — slice 1.

@Astro-Han
Astro-Han marked this pull request as draft August 2, 2026 03:12
@Astro-Han
Astro-Han force-pushed the refactor/ui-composer-chat-input branch 2 times, most recently from e1ce5ef to 1cdc555 Compare August 2, 2026 08:42
@Astro-Han
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
Astro-Han force-pushed the refactor/ui-composer-chat-input branch from ad87a6f to 585d3e8 Compare August 2, 2026 10:08
@Astro-Han
Astro-Han merged commit c45a93d into main Aug 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant