Skip to content

Unify the composer into one card and submit on Enter - #30

Merged
leoncheng57 merged 2 commits into
mainfrom
feat/composer-card-enter-to-send
Aug 21, 2026
Merged

Unify the composer into one card and submit on Enter#30
leoncheng57 merged 2 commits into
mainfrom
feat/composer-card-enter-to-send

Conversation

@leoncheng57

@leoncheng57 leoncheng57 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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 640px sm:flex swaps the display mode, every grid placement class goes inert, and the children become flex siblings under the default align-items: stretch:

Child Height at sm: Result
Attach <label> sm:min-h-0, auto stretches to the textarea → tall empty box
reminder <select> sm:min-h-0, auto stretches → oversized dropdown
<textarea> rows={4} tallest item, sets the row height
Send <Button> h-9 fixed declared height opts out of stretch → pins to the top

So the two dead-space boxes and the floating Send button were the same bug.

The change

┌─ rounded-xl border, bg-surface, focus-within:border-focus ──────────────┐
│  Send a follow-up…                                                      │
│                                          auto-grows 96px → max 256px    │
├─────────────────────────────────────────────────────────────────────────┤
│  Attach   + reminder ▾                                        [ Send ]  │
└─────────────────────────────────────────────────────────────────────────┘

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.
  • The mode/model meta line no longer truncates to Read-only an...; it wraps to its own line instead of competing with the model pickers.
  • The composer gains a focus indication it never had.

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:

Keys Behavior
Enter, fine pointer submit
Enter, coarse pointer newline — Enter is the key users reach for on a soft keyboard
Shift+Enter newline, always
Cmd/Ctrl+Enter submit, any device
Enter while an IME composes newline — never send a half-typed candidate
Enter on an unsendable draft swallowed, not a blank line

send() has no re-entry guard and prompt_async returns as soon as the turn is queued, so the same guard stops a fast double Enter from posting two turns.

enterKeyHint stays "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 of composerEnterAction.
  • Behavior verified in a real browser against the mock stack: Shift+Enter produced 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:e2e could not be run locally — see below. CI should be treated as the gate for it.

Two environment findings, neither caused by this PR

  1. res.sendFile is broken on Node 26. With node v26.3.0 / express 5.2.1 / send 1.2.1, res.sendFile(<absolute path that exists>) returns 404, so the SPA history fallback in server/index.ts fails and every deep link 404s. Reproduced in isolation and confirmed on a clean checkout of main. package.json declares engines: node >=22; this only bites on 26.

  2. reuseExistingServer makes concurrent worktrees collide. playwright.config.ts uses fixed ports with reuseExistingServer: !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, so hasTouch does 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 a Cmd/Ctrl+Enter case that is faithful under emulation.

Screenshots

# Composer card: textarea + toolbar rail sharing one frame, controls on one baseline
/sessions/ses_mock_running?directory=/tmp/mock-project
# Model/variant pickers with the meta line wrapped instead of truncated
/sessions/ses_mock_unknown_model?directory=/tmp/mock-project
# Second content shape, same composer
/sessions/ses_mock_mobile?directory=/tmp/mock-project

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_done is 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, because
main is h-full overflow-hidden so the document is exactly viewport height and a
full-page capture is identical to a viewport one.

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.
github-actions Bot added a commit that referenced this pull request Aug 21, 2026
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.
github-actions Bot added a commit that referenced this pull request Aug 21, 2026
@leoncheng57
leoncheng57 merged commit ca7d26f into main Aug 21, 2026
3 checks passed
github-actions Bot added a commit that referenced this pull request Aug 21, 2026
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