fix(coach): keep the composer one row tall until someone types

On a phone the composer stacked its controls under the textarea, so an EMPTY
input was two rows tall: a text line, a 44 px tap-target row, and padding. That
is roughly a tenth of a phone screen spent before anyone has typed anything, and
it is the first thing on the screen when the Coach opens.

The auto-grow was never the problem. The textarea already starts at one line and
grows to six; it was the layout around it that reserved the second row up front.

The stacking existed to protect keyboard focus order. That argument does not
hold: focus follows DOM order, and the DOM order is textarea, actions, Send in
both layouts. Nothing jumps. `items-end` keeps the controls beside the last line
as the textarea grows, so the send button stays where the thumb left it.

One component serves both surfaces — the conversation view and the hero, which
re-parents the same live composer — so this is one fix rather than two.

The test that asserted the stacked layout now asserts the row, with the reason
written down, and it fails when the stack comes back.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 15:59:36 +02:00
1 parent 8a2bb70dbd
commit d7badf1512
2 files changed
+27 -9

No files matched your search

@@ -228,7 +228,7 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-send"');
});
it("puts the textarea above a split control row on phones", () => {
it("keeps the textarea and its controls on one row at every width", () => {
const html = render(
<CoachInput
value=""
@@ -258,10 +258,20 @@ describe("<CoachInput>", () => {
const controls = html.match(
/<div[^>]*data-slot="coach-input-controls"[^>]*>/,
);
expect(controls?.[0]).toMatch(/\bw-full\b/);
expect(controls?.[0]).toMatch(/\bjustify-between\b/);
expect(controls?.[0]).toMatch(/\bsm:w-auto\b/);
expect(controls?.[0]).not.toMatch(/\bsm:contents\b/);
// The controls sit BESIDE the textarea, not under it. Stacking them made an
// empty composer two rows tall — a text line plus a 44 px tap-target row —
// which is roughly a tenth of a phone screen before anyone has typed. The
// controls must therefore never claim the full width or push themselves
// onto their own line.
expect(controls?.[0]).not.toMatch(/\bw-full\b/);
expect(controls?.[0]).not.toMatch(/\bjustify-between\b/);
expect(controls?.[0]).toMatch(/\bshrink-0\b/);
// And the row itself is a row at every width, bottom-aligned so the send
// button stays beside the last line as the textarea grows.
const shell = html.match(/<div[^>]*class="[^"]*\bflex-row\b[^"]*"[^>]*>/);
expect(shell?.[0]).toMatch(/\bitems-end\b/);
expect(shell?.[0]).not.toMatch(/\bflex-col\b/);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
@@ -419,9 +419,17 @@ export function CoachInput({
"border-border/60 bg-muted/40 group rounded-2xl border",
"shadow-sm transition-colors",
"focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2",
// Mobile uses two rows. Desktop keeps the same DOM and visual order
// (textarea, actions, Send) so keyboard focus never jumps backwards.
"flex flex-col gap-1.5 p-1.5 sm:flex-row sm:items-end",
// One row at every width. Mobile used to stack the controls under the
// textarea, which made an EMPTY composer two rows tall: a 44 px line
// plus a 44 px touch-target row plus padding, around a tenth of a
// phone screen before anyone had typed anything. The stacking was
// there to protect keyboard focus order, but that argument does not
// hold: focus follows DOM order, and the DOM order (textarea,
// actions, Send) is identical in both layouts. Nothing jumps.
//
// `items-end` keeps the controls beside the LAST line as the textarea
// grows, so the send button stays where the thumb left it.
"flex flex-row items-end gap-1.5 p-1.5",
)}
>
<textarea
@@ -447,7 +455,7 @@ export function CoachInput({
/>
<div
data-slot="coach-input-controls"
className="order-2 flex w-full items-center justify-between gap-1.5 sm:w-auto sm:shrink-0 sm:justify-start"
className="order-2 flex shrink-0 items-center gap-1.5"
>
<div
data-slot="coach-input-leading"