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({ >