Unify the composer into one card and submit on Enter - #30
Merged
Conversation
The footer laid the composer out with `grid ... sm:flex`, so above 640px the grid placements went inert and the children became flex siblings under the default `align-items: stretch`. Attach and the reminder select inflated to the textarea's height while the fixed-height Send button opted out of stretch and pinned to the top of the row, leaving two tall empty boxes and a misaligned button. Give the textarea and its controls one bordered card instead: the textarea sits on top, borderless and transparent, and Attach / reminder / Send share a toolbar rail beneath it. Nothing needs to stretch to match the textarea, so the alignment problem stops existing rather than being patched with height overrides. The card carries focus-within styling, which the composer previously had none of. Also: - Replace `resize-y` (and its grabber nub) with auto-grow from the 96px floor up to a 256px cap, after which the textarea scrolls. - Stop truncating the mode/model meta line to "Read-only an..."; it wraps to its own line instead of fighting the model pickers for space. Enter now submits and Shift+Enter inserts a newline. The decision lives in composerKeys.ts as a pure function so its branches are unit tested: an IME committing a candidate never sends, a coarse primary pointer keeps Enter as a newline because that is the key users reach for on a soft keyboard, Cmd/Ctrl+ Enter submits anywhere, and Enter on an unsendable draft is swallowed rather than silently adding a blank line. `send()` has no re-entry guard and prompt_async returns as soon as the turn is queued, so the guard also blocks a fast double Enter from posting two turns. `enterKeyHint` stays "enter": on a phone Enter really is a newline here.
Two CI failures from the previous commit. The composer card was taller than the row it replaced, and a session showing the auto-permission, interrupted, permission and question banners at once already left the transcript a sliver of Playwright's 1280x720 viewport. The extra height pushed the first tool row under the footer, so `transcript > expands a tool call` timed out clicking it. Give the textarea a 64px floor on `sm:` and up (the 96px floor is mobile-only, where the layout is measured), tighten the toolbar rail to `sm:py-1`, and drop the controls to `sm:min-h-8` with the small Button size. Measured on the same session at the same viewport, the footer is now 201px against main's 245px, so the transcript gets more room than before this branch, not less. `composer > does not submit an empty or whitespace-only draft on Enter` asserted that the whole transcript was byte-identical before and after pressing Enter, but ses_mock_done is shared with every other composer test and with the parallel screenshot spec, so unrelated messages landed mid-assertion. Assert the local invariants instead: the draft is kept rather than cleared, and Send stays disabled. Mobile geometry is unchanged: composer 96px, Attach and Send 44px, composer more than twice the reminder width, no horizontal overflow.
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.
Fixes the composer footer's layout and makes Enter send.
The layout bug
The footer container was
grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] gap-2 sm:flex. Above 640pxsm:flexswaps the display mode, every grid placement class goes inert, and the children become flex siblings under the defaultalign-items: stretch:sm:<label>sm:min-h-0, auto<select>sm:min-h-0, auto<textarea>rows={4}<Button>h-9fixedSo the two dead-space boxes and the floating Send button were the same bug.
The change
One card owns the border; the textarea is borderless/transparent; the controls get their own rail. Nothing needs to stretch to match the textarea, so the alignment problem is removed rather than patched with height overrides. Measured in-browser, the three controls now share an identical baseline (
top: 943,height: 36).Also in scope:
resize-y→ auto-grow, removing the resize grabber. Floors at 96px, caps at 256px, then scrolls.Read-only an...; it wraps to its own line instead of competing with the model pickers.Out of scope by request: the Hub "New task" card and the conversation header.
Enter to send
Enter submits, Shift+Enter inserts a newline. The policy is a pure function in
client/lib/composerKeys.ts:Enter, fine pointerEnter, coarse pointerShift+EnterCmd/Ctrl+EnterEnterwhile an IME composesEnteron an unsendable draftsend()has no re-entry guard andprompt_asyncreturns as soon as the turn is queued, so the same guard stops a fast double Enter from posting two turns.enterKeyHintstays"enter"— on a phone Enter really is a newline here, so the hint remains accurate and the existing mobile assertion is untouched.Testing
npm run typecheck— clean.npm test— 209 passing (was 202); 7 new cases cover every branch ofcomposerEnterAction.first line\nsecond line, plain Enter submitted it, both messages appear in the transcript, the box auto-grows 96 → 256 then scrolls and returns to 96 when cleared, and Enter on an empty draft inserted nothing.npm run test:e2ecould not be run locally — see below. CI should be treated as the gate for it.Two environment findings, neither caused by this PR
res.sendFileis broken on Node 26. Withnode v26.3.0/express 5.2.1/send 1.2.1,res.sendFile(<absolute path that exists>)returns 404, so the SPA history fallback inserver/index.tsfails and every deep link 404s. Reproduced in isolation and confirmed on a clean checkout ofmain.package.jsondeclaresengines: node >=22; this only bites on 26.reuseExistingServermakes concurrent worktrees collide.playwright.config.tsuses fixed ports withreuseExistingServer: !process.env.CI, so a run in one worktree silently attaches to another worktree's server — I watched runs here bind to servers owned by two sibling worktrees mid-vite build, producing 23 confusing failures that had nothing to do with the code under test. Worth a follow-up.The e2e mobile test for the coarse-pointer branch was deliberately not added: Playwright launches Chromium with a browser-level
--blink-settings=primaryPointerType=fine, sohasTouchdoes not move(pointer: coarse)and such a test would pass or fail for the wrong reason. That branch is covered by unit tests instead; the e2e suite keeps aCmd/Ctrl+Entercase that is faithful under emulation.Screenshots
Each route was checked at the capture viewport (1280x800) before being requested: the
composer card is fully framed in all three (card spans y=637..788 of 800).
ses_mock_doneis deliberately not requested. That session stacks four alert banners(auto-permissions, interrupted run, permission request, question request) which push the
card to y=705..856, past the 800px viewport.
full:cannot recover it either, becausemainish-full overflow-hiddenso the document is exactly viewport height and afull-page capture is identical to a viewport one.