feat(chat): Enter sends on desktop, Shift+Enter newline; touch keeps return-as-newline #70

Closed
opened 2026-07-09 13:50:13 +02:00 by dominik.polakovics · 1 comment

Summary

Change the submit key in both web composers: bare Enter submits on devices with a physical-pointer setup; Shift+Enter inserts a newline. Today submit is Cmd/Ctrl+Enter only and bare Enter is always a newline.

This intentionally reverses the "bare Enter stays a newline" clause of ADR-0022/ADR-0029 (it was a considered-and-rejected option in ADR-0022). All decisions below were resolved with the maintainer in a grill session on 2026-07-09.

Decided behavior

  1. Bare Enter sends — on fine-pointer devices only. Gate on window.matchMedia('(hover: hover) and (pointer: fine)').matches (evaluate per keydown or via a listener; do not snapshot once at mount, so docking/undocking a keyboard on hybrids behaves). On touch devices the virtual keyboard's return key keeps inserting a newline and the on-screen Send button remains the submit affordance — preserving the phone-first rationale of ADR-0005/0022. Do not set enterkeyhint="send" (the platform offers only one action key; relabeling it would cost phone users keyboard newlines).
  2. Shift+Enter inserts a newline everywhere. Implementation: simply don't intercept Enter when shiftKey is held — the browser default does the rest. (Alt+Enter likewise falls through.)
  3. Cmd/Ctrl+Enter keeps sending in all environments, as today. It remains the keyboard submit path on touch + hardware-keyboard setups.
  4. Enter sends in all agent states — idle, needs_input, and working alike — exactly matching the Cmd/Ctrl+Enter contract ADR-0029 established. No state where the key silently changes meaning.
  5. Both composers: the chat reply textarea (web/src/routes/RunChat.tsx, keydown handler ~lines 1670–1699) and the new-run composer (web/src/routes/NewRun.tsx, ~lines 269–274, whose comment says it mirrors the chat composer). Consider extracting the shared gate into one helper so the two cannot drift.
  6. Slash-command popover precedence unchanged: while the popover is open, Enter/Tab accept the highlighted command (existing RunChat.tsx logic); the send branch runs only when the popover is closed.
  7. IME guard (new requirement): ignore Enter when e.isComposing is true (and the legacy e.keyCode === 229 case) so committing CJK composition with Enter does not fire a send. Currently absent everywhere in web/src — it was safe under Ctrl+Enter, it is not under bare Enter.
  8. Empty input: bare Enter on an empty/whitespace-only textarea must not send; also preventDefault it so it does not insert a leading newline into an empty composer (match/keep the existing send() guard).
  9. Tooltip: update NewRun.tsx title="Start run (Cmd/Ctrl+Enter)" to reflect Enter (e.g. "Start run (Enter)"). The chat Send button has no shortcut hint; nothing to change there.

Tests

  • Rewrite web/src/routes/RunChat.test.tsx:360–380 ("sends on Cmd/Ctrl+Enter but never on bare Enter") to the new contract: bare Enter sends on fine-pointer, Shift+Enter does not send (newline), Ctrl/Cmd+Enter still sends.
  • Keep/extend the ADR-0029 working-state test (~lines 468–484) to cover bare Enter while working.
  • New: touch profile (mock matchMedia coarse/no-hover) — bare Enter does not send.
  • New: isComposing: true Enter does not send.
  • New: equivalent keyboard test for the NewRun.tsx composer (currently untested).

ADR

Add a new ADR (next free number) recording this decision and superseding the "bare Enter stays a newline in every state" clauses of ADR-0022 (docs/adr/0022-chat-composer-send-interrupt-morph.md, incl. the rejected-option entry) and ADR-0029 (docs/adr/0029-chat-composer-always-send-interrupt-relocation.md); annotate those two files' status/compat notes accordingly. Everything else in ADR-0029 (always-send shortcut, header interrupt) remains in force.

Out of scope

  • No user-facing setting/toggle for the behavior (explicitly decided against for now).
  • No enterkeyhint changes.
  • No Go/API/provider changes — pure SPA diff (web/src + tests + ADR).
## Summary Change the submit key in both web composers: **bare Enter submits** on devices with a physical-pointer setup; **Shift+Enter inserts a newline**. Today submit is Cmd/Ctrl+Enter only and bare Enter is always a newline. This intentionally reverses the "bare Enter stays a newline" clause of ADR-0022/ADR-0029 (it was a considered-and-rejected option in ADR-0022). All decisions below were resolved with the maintainer in a grill session on 2026-07-09. ## Decided behavior 1. **Bare Enter sends — on fine-pointer devices only.** Gate on `window.matchMedia('(hover: hover) and (pointer: fine)').matches` (evaluate per keydown or via a listener; do not snapshot once at mount, so docking/undocking a keyboard on hybrids behaves). On touch devices the virtual keyboard's return key keeps inserting a newline and the on-screen Send button remains the submit affordance — preserving the phone-first rationale of ADR-0005/0022. Do **not** set `enterkeyhint="send"` (the platform offers only one action key; relabeling it would cost phone users keyboard newlines). 2. **Shift+Enter inserts a newline** everywhere. Implementation: simply don't intercept Enter when `shiftKey` is held — the browser default does the rest. (Alt+Enter likewise falls through.) 3. **Cmd/Ctrl+Enter keeps sending** in all environments, as today. It remains the keyboard submit path on touch + hardware-keyboard setups. 4. **Enter sends in all agent states** — idle, needs_input, and working alike — exactly matching the Cmd/Ctrl+Enter contract ADR-0029 established. No state where the key silently changes meaning. 5. **Both composers**: the chat reply textarea (`web/src/routes/RunChat.tsx`, keydown handler ~lines 1670–1699) and the new-run composer (`web/src/routes/NewRun.tsx`, ~lines 269–274, whose comment says it mirrors the chat composer). Consider extracting the shared gate into one helper so the two cannot drift. 6. **Slash-command popover precedence unchanged**: while the popover is open, Enter/Tab accept the highlighted command (existing `RunChat.tsx` logic); the send branch runs only when the popover is closed. 7. **IME guard (new requirement)**: ignore Enter when `e.isComposing` is true (and the legacy `e.keyCode === 229` case) so committing CJK composition with Enter does not fire a send. Currently absent everywhere in `web/src` — it was safe under Ctrl+Enter, it is not under bare Enter. 8. **Empty input**: bare Enter on an empty/whitespace-only textarea must not send; also `preventDefault` it so it does not insert a leading newline into an empty composer (match/keep the existing `send()` guard). 9. **Tooltip**: update `NewRun.tsx` `title="Start run (Cmd/Ctrl+Enter)"` to reflect Enter (e.g. "Start run (Enter)"). The chat Send button has no shortcut hint; nothing to change there. ## Tests - Rewrite `web/src/routes/RunChat.test.tsx:360–380` ("sends on Cmd/Ctrl+Enter but never on bare Enter") to the new contract: bare Enter sends on fine-pointer, Shift+Enter does not send (newline), Ctrl/Cmd+Enter still sends. - Keep/extend the ADR-0029 working-state test (~lines 468–484) to cover bare Enter while working. - New: touch profile (mock `matchMedia` coarse/no-hover) — bare Enter does **not** send. - New: `isComposing: true` Enter does not send. - New: equivalent keyboard test for the `NewRun.tsx` composer (currently untested). ## ADR Add a new ADR (next free number) recording this decision and superseding the "bare Enter stays a newline in every state" clauses of ADR-0022 (`docs/adr/0022-chat-composer-send-interrupt-morph.md`, incl. the rejected-option entry) and ADR-0029 (`docs/adr/0029-chat-composer-always-send-interrupt-relocation.md`); annotate those two files' status/compat notes accordingly. Everything else in ADR-0029 (always-send shortcut, header interrupt) remains in force. ## Out of scope - No user-facing setting/toggle for the behavior (explicitly decided against for now). - No `enterkeyhint` changes. - No Go/API/provider changes — pure SPA diff (`web/src` + tests + ADR).
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #71 (afk/70) → VERDICT: PASS

AFK contract ✓ — head afk/70, body carries Closes #70, title is Conventional Commits (feat(chat): …).

Correctness review of the diff ✓ — implements all 9 decided behaviors:

  • Shared isComposerSend gate (web/src/lib/composerKeys.ts) that both composers call, so they cannot drift. Bare Enter sends iff matchMedia('(hover: hover) and (pointer: fine)') matches, evaluated per-keydown (never snapshotted); the optional ?. makes an absent matchMedia read as not-fine-pointer (touch-safe). Cmd/Ctrl+Enter always sends; Shift/Alt+Enter are never intercepted; a new IME guard (isComposing / legacy keyCode === 229) blocks sends mid-composition.
  • RunChat.tsx: popover precedence preserved — the Enter/Tab accept branch calls preventDefault() and returns before the send gate is reached; Cmd/Ctrl+Enter still sends over the popover.
  • NewRun.tsx: bare Enter on an empty box preventDefaults (no leading newline) then bails without spawning, while Cmd/Ctrl+Enter keeps the explicit empty plain-spawn path; tooltip updated to "Start run (Enter)".
  • ADR-0031 added; ADR-0022 (incl. its rejected-option entry) and ADR-0029 annotated in place with precise supersession notes — touch behavior explicitly unchanged.

Verification signal — the repo's required native CI check (.forgejo/workflows/ci.yml: SPA eslint + prettier + vitest + vite build, then the Go ui build/test) covers exactly this diff; ci-nix is path-gated to **/*.nix / flake.lock / go.mod / go.sum, none of which this pure-web/src diff touches, so it correctly does not run. The forge CI status is not readable from the lab sandbox (no forge token), so I re-ran the SPA gate locally on the PR head (eff2f33): eslint clean, prettier clean, 558/558 tests green (37 files), and tsc --noEmit && vite build clean — matching the PR body.

Mergeability ✓ — branches directly off current main; git merge-tree reports no conflicts.

No blockers. Awaiting the maintainer's free-text go-ahead to merge.

> *This was generated by AI while landing a PR.* **Landing audit — PR #71 (`afk/70`) → VERDICT: PASS** **AFK contract** ✓ — head `afk/70`, body carries `Closes #70`, title is Conventional Commits (`feat(chat): …`). **Correctness review of the diff** ✓ — implements all 9 decided behaviors: - Shared `isComposerSend` gate (`web/src/lib/composerKeys.ts`) that both composers call, so they cannot drift. Bare Enter sends iff `matchMedia('(hover: hover) and (pointer: fine)')` matches, evaluated per-keydown (never snapshotted); the optional `?.` makes an absent `matchMedia` read as not-fine-pointer (touch-safe). Cmd/Ctrl+Enter always sends; Shift/Alt+Enter are never intercepted; a new IME guard (`isComposing` / legacy `keyCode === 229`) blocks sends mid-composition. - `RunChat.tsx`: popover precedence preserved — the Enter/Tab accept branch calls `preventDefault()` and returns before the send gate is reached; Cmd/Ctrl+Enter still sends over the popover. - `NewRun.tsx`: bare Enter on an empty box `preventDefault`s (no leading newline) then bails without spawning, while Cmd/Ctrl+Enter keeps the explicit empty plain-spawn path; tooltip updated to "Start run (Enter)". - ADR-0031 added; ADR-0022 (incl. its rejected-option entry) and ADR-0029 annotated in place with precise supersession notes — touch behavior explicitly unchanged. **Verification signal** — the repo's required `native` CI check (`.forgejo/workflows/ci.yml`: SPA eslint + prettier + vitest + `vite build`, then the Go `ui` build/test) covers exactly this diff; `ci-nix` is path-gated to `**/*.nix` / `flake.lock` / `go.mod` / `go.sum`, none of which this pure-`web/src` diff touches, so it correctly does not run. The forge CI status is not readable from the lab sandbox (no forge token), so I re-ran the SPA gate locally on the PR head (`eff2f33`): eslint clean, prettier clean, **558/558 tests green (37 files)**, and `tsc --noEmit && vite build` clean — matching the PR body. **Mergeability** ✓ — branches directly off current `main`; `git merge-tree` reports no conflicts. No blockers. Awaiting the maintainer's free-text go-ahead to merge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#70
No description provided.