From bc92651509cc6bfa9302800608b847b2d291b962 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 8 Nov 2026 18:42:27 -0800 Subject: [PATCH] fix(web): preserve agent composer shrink sizing --- .../components/agent/agent-conversation.tsx | 27 ++++++++++++++----- web/tests/agent-composer-layout.browser.mjs | 8 ++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index eee3d29..12ab5d7 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -46,6 +46,23 @@ function dayLabel(date: Date | null) { today.setDate(today.getDate() - 1); return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date); } +function measureComposerHeight(textarea: HTMLTextAreaElement) { + const measurement = textarea.cloneNode(false) as HTMLTextAreaElement; + measurement.removeAttribute("id"); + measurement.removeAttribute("name"); + measurement.setAttribute("aria-hidden", "true"); + measurement.tabIndex = -1; + measurement.value = textarea.value; + Object.assign(measurement.style, { + position: "fixed", left: "-10000px", top: "0", visibility: "hidden", pointerEvents: "none", + width: `${textarea.getBoundingClientRect().width}px`, height: "0px", minHeight: "0px", + maxHeight: "none", overflow: "hidden", resize: "none", + }); + textarea.parentElement?.appendChild(measurement); + const height = measurement.scrollHeight; + measurement.remove(); + return height; +} function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } @@ -288,12 +305,10 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro useLayoutEffect(() => { const textarea = textareaRef.current; if (!textarea) return; - // scrollHeight reflects the full draft even when the textarea is already - // capped. Avoid collapsing a focused field to its minimum height while - // measuring: the browser may scroll the enclosing transcript to reveal its - // caret, and no ResizeObserver can correct that transient scroll once the - // field returns to the same capped height. - textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`; + // Measure offscreen: collapsing a focused textarea can make its scroll + // parent reveal the caret, while its own scrollHeight cannot measure a + // smaller draft until its current height has been removed. + textarea.style.height = `${Math.min(156, Math.max(46, measureComposerHeight(textarea)))}px`; }, [draft]); const updateDraft = (value: string) => { diff --git a/web/tests/agent-composer-layout.browser.mjs b/web/tests/agent-composer-layout.browser.mjs index 070c0ce..fc66ea1 100644 --- a/web/tests/agent-composer-layout.browser.mjs +++ b/web/tests/agent-composer-layout.browser.mjs @@ -107,11 +107,19 @@ try { if (process.env.RECORD_ONLY !== '1') for (const result of all) { for (const sample of result.bottom) assert.ok(Math.abs(sample.gap) <= 1, `bottom pin drift at ${result.config.width}px/${result.config.dpr}x: ${JSON.stringify(sample)}`); + const byLabel = Object.fromEntries(result.bottom.map(sample => [sample.label, sample])); + assert.equal(byLabel['wrapped-and-capped'].textareaHeight, 156, `textarea cap changed: ${JSON.stringify(result.bottom)}`); + assert.ok(byLabel.shrink.textareaHeight <= 48 && byLabel.cleared.textareaHeight <= 48, + `textarea did not shrink with the draft: ${JSON.stringify(result.bottom)}`); + assert.equal(byLabel.cleared.clientHeight, byLabel.empty.clientHeight, + `composer did not return to its empty height: ${JSON.stringify(result.bottom)}`); const before = result.nonbottom.find(sample => sample.before)?.before; for (const sample of result.nonbottom.filter(sample => sample.label?.startsWith('nonbottom-'))) { assert.equal(sample.anchorId, before.anchorId, `nonbottom anchor changed: ${JSON.stringify({ before, sample })}`); assert.ok(Math.abs(sample.anchorTop - before.anchorTop) <= 1, `nonbottom transcript jumped: ${JSON.stringify({ before, sample })}`); } + const nonbottomShrink = result.nonbottom.find(sample => sample.label === 'nonbottom-shrink'); + assert.ok(nonbottomShrink.textareaHeight <= 48, `nonbottom textarea did not shrink: ${JSON.stringify(nonbottomShrink)}`); } process.stdout.write(JSON.stringify({ browser, all }, null, 2) + '\n'); } finally {