From c7eb2269695ef71e750945aff3643f148cc5af93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:32:28 +0200 Subject: [PATCH] feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer starts at a single-line height (matching the disclaimer text on the left of the row) and grows with content up to six lines. Past that point the textarea scrolls internally so the rest of the drawer never moves. Implementation is the standard scrollHeight measurement — reset to "auto", read scrollHeight, clamp to [lineHeight + padding, 6 × lineHeight + padding]. The pure `computeAutoGrowHeight` helper backs the effect for unit testing. --- .../__tests__/coach-input.test.tsx | 51 +++++++++++- .../insights/coach-panel/coach-input.tsx | 81 ++++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index bc9beb678..e5542b4ab 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -121,10 +121,57 @@ describe("", () => { expect(handler).toHaveBeenCalledWith("typed"); }); - it("renders the textarea with rows=2 to match the artboard", () => { + it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => { + // v1.4.25 W5 — Claude-web-style auto-grow. SSR baseline is a + // single-line textarea; the client-side `useEffect` measures + // `scrollHeight` and grows the height up to ~6 lines. The static + // markup must show `rows="1"` so the initial paint matches the + // disclaimer text height on the left side of the row. const html = render( {}} onSubmit={() => {}} />, ); - expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/); + expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="1"/); + // The textarea caps growth via the `max-h-[9.5rem]` class so the + // composer never pushes the rest of the drawer off-screen. + expect(html).toMatch( + /data-slot="coach-input-textarea"[^>]*class="[^"]*max-h-\[9\.5rem\]/, + ); + }); +}); + +// v1.4.25 W5 — `computeAutoGrowHeight` is the pure helper backing the +// textarea's auto-grow effect. Pin its math so the textarea never +// collapses below a single line or grows past the 6-line ceiling. +import { computeAutoGrowHeight } from "../coach-input"; + +describe("computeAutoGrowHeight", () => { + it("clamps to the single-line minimum when scrollHeight is empty", () => { + const out = computeAutoGrowHeight({ + lineHeight: 20, + scrollHeight: 0, + maxLines: 6, + paddingY: 10, + }); + expect(out).toBe(20 + 10); + }); + + it("returns the natural scrollHeight when below the cap", () => { + const out = computeAutoGrowHeight({ + lineHeight: 20, + scrollHeight: 60, + maxLines: 6, + paddingY: 10, + }); + expect(out).toBe(60); + }); + + it("caps at maxLines × lineHeight + paddingY", () => { + const out = computeAutoGrowHeight({ + lineHeight: 20, + scrollHeight: 999, + maxLines: 6, + paddingY: 10, + }); + expect(out).toBe(20 * 6 + 10); }); }); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 016a9ca0a..05e7338d2 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -1,6 +1,12 @@ "use client"; -import { type FormEvent, type KeyboardEvent, useCallback } from "react"; +import { + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useRef, +} from "react"; import { Loader2, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -40,6 +46,41 @@ export interface CoachInputProps { inputId?: string; } +/** + * v1.4.25 W5 — Claude-web-style auto-grow. + * + * The composer textarea starts at 1 line (≈44 px including padding, + * matching the disclaimer text height on the sources rail) and grows + * with content up to 6 lines (≈144 px). Past 6 lines the textarea + * scrolls internally. Implemented as a plain `scrollHeight` measurement + * — no external auto-resize library, no `contenteditable` round-trip, + * no `field-sizing` (still Chrome-only in 2026-Q2). + * + * Pure helper so the math can be unit-tested without a DOM: + * - `lineHeight`: cached line-height of the textarea (px) + * - `scrollHeight`: textarea's natural `scrollHeight` after height + * was reset to "auto" + * - `maxLines`: hard cap (6); past this the textarea scrolls + * Returns the height in px to apply, clamped to [minHeight, maxHeight]. + */ +export function computeAutoGrowHeight(args: { + lineHeight: number; + scrollHeight: number; + maxLines: number; + paddingY: number; +}): number { + const min = args.lineHeight + args.paddingY; + const max = args.lineHeight * args.maxLines + args.paddingY; + // Honour the existing scrollHeight (which already includes padding) + // but clamp it into the [min,max] band. The minimum guarantees the + // textarea never collapses below a single line when the value is + // empty; the maximum prevents the row from pushing the composer + // taller than 6 lines. + return Math.max(min, Math.min(args.scrollHeight, max)); +} + +const AUTO_GROW_MAX_LINES = 6; + export function CoachInput({ value, onChange, @@ -49,6 +90,35 @@ export function CoachInput({ inputId = "coach-composer-textarea", }: CoachInputProps) { const { t } = useTranslations(); + const textareaRef = useRef(null); + + // v1.4.25 W5 — auto-grow side effect. Runs on every value change + // (the controlled `value` prop is the source of truth) plus on mount + // so the initial paint already shows the 1-line height even when the + // value arrives prefilled. We reset `style.height` to "auto" first so + // shrinking text (e.g. after the user hits send) also collapses the + // textarea — without the reset, `scrollHeight` only ever monotonically + // grows. Reads `getComputedStyle` for line-height + padding once per + // tick so a CSS-only theme change re-applies the right minimum. + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + // Reset so the browser re-measures the natural content height. + el.style.height = "auto"; + const computed = window.getComputedStyle(el); + const rawLineHeight = parseFloat(computed.lineHeight); + const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 20; + const paddingY = + (parseFloat(computed.paddingTop) || 0) + + (parseFloat(computed.paddingBottom) || 0); + const height = computeAutoGrowHeight({ + lineHeight, + scrollHeight: el.scrollHeight, + maxLines: AUTO_GROW_MAX_LINES, + paddingY, + }); + el.style.height = `${height}px`; + }, [value]); const canSubmit = !disabled && value.trim().length > 0; @@ -85,15 +155,22 @@ export function CoachInput({ >