From dabf2d94cecfc25eb990a9eab604bf9401e27c72 Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Fri, 10 Jul 2026 09:47:44 -0400 Subject: [PATCH 1/2] fix(ui): keep Ask AI input visible after responses Long completed responses caused the h-full chat flex item to retain its automatic minimum height, pushing the follow-up composer below the sidebar viewport. Let the panel consume the remaining flex space with min-h-0 so only the message area scrolls. --- .../ui/components/ai/DocumentAIChatPanel.tsx | 2 +- .../browser/ai-chat-input-layout/fixture.html | 12 ++ .../browser/ai-chat-input-layout/fixture.tsx | 128 ++++++++++++++++++ tests/browser/ai-chat-input-layout/run.sh | 71 ++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/browser/ai-chat-input-layout/fixture.html create mode 100644 tests/browser/ai-chat-input-layout/fixture.tsx create mode 100755 tests/browser/ai-chat-input-layout/run.sh diff --git a/packages/ui/components/ai/DocumentAIChatPanel.tsx b/packages/ui/components/ai/DocumentAIChatPanel.tsx index b43bf3d2d..d08a0fdda 100644 --- a/packages/ui/components/ai/DocumentAIChatPanel.tsx +++ b/packages/ui/components/ai/DocumentAIChatPanel.tsx @@ -84,7 +84,7 @@ export const DocumentAIChatPanel: React.FC = ({ }, [generalInput, onAskGeneral]); return ( -
+
{messages.length === 0 && !isCreatingSession && ( diff --git a/tests/browser/ai-chat-input-layout/fixture.html b/tests/browser/ai-chat-input-layout/fixture.html new file mode 100644 index 000000000..fc54a3e5e --- /dev/null +++ b/tests/browser/ai-chat-input-layout/fixture.html @@ -0,0 +1,12 @@ + + + + + + AI chat input layout regression + + +
+ + + diff --git a/tests/browser/ai-chat-input-layout/fixture.tsx b/tests/browser/ai-chat-input-layout/fixture.tsx new file mode 100644 index 000000000..6fe35081f --- /dev/null +++ b/tests/browser/ai-chat-input-layout/fixture.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import '@plannotator/editor/styles'; +import { DocumentAIChatPanel } from '@plannotator/ui/components/ai/DocumentAIChatPanel'; +import type { AIChatEntry } from '@plannotator/ui/hooks/useAIChat'; +import type { AIProviderOption } from '@plannotator/ui/utils/aiProvider'; + +const completed = new URLSearchParams(location.search).get('state') !== 'streaming'; +const longResponse = Array.from( + { length: 8 }, + (_, i) => `Paragraph ${i + 1}: This completed response is long enough to overflow the chat viewport and trigger automatic scrolling.`, +).join('\n\n'); +const messages: AIChatEntry[] = [ + { + question: { + id: 'q1', + prompt: 'Can we use the actual codec API?', + createdAt: Date.now(), + scope: { + kind: 'selection', + label: 'Selected text', + text: 'export function codec() {}', + }, + }, + response: { + text: completed ? longResponse : '', + isStreaming: !completed, + }, + }, +]; +const providers: AIProviderOption[] = [ + { + id: 'pi-sdk', + name: 'Pi', + available: true, + models: [{ id: 'gpt-5.5', label: 'GPT-5.5', default: true }], + }, +]; + +function requireElement(selector: string, type: new () => T): T { + const element = document.querySelector(selector); + if (!(element instanceof type)) { + throw new Error(`Expected ${selector} to be a ${type.name}`); + } + return element; +} + +function rect(element: Element) { + const { top, bottom, height } = element.getBoundingClientRect(); + return { top, bottom, height }; +} + +function App() { + return ( +
+ +
+ ); +} + +const root = requireElement('#root', HTMLElement); +createRoot(root).render(); +requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(() => { + const viewportEl = requireElement('#viewport', HTMLElement); + const panelEl = requireElement('#panel', HTMLElement); + const chatEl = panelEl.lastElementChild; + if (!(chatEl instanceof HTMLElement)) { + throw new Error('Expected the chat panel to render as the final sidebar child'); + } + const scrollEl = chatEl.firstElementChild; + if (!(scrollEl instanceof HTMLElement)) { + throw new Error('Expected the chat panel to render a scroll viewport'); + } + const inputEl = requireElement('textarea', HTMLTextAreaElement); + const viewport = viewportEl.getBoundingClientRect(); + const input = inputEl.getBoundingClientRect(); + const visible = input.top >= viewport.top + && input.bottom <= viewport.bottom + && input.height > 0; + + document.body.dataset.verdict = visible ? 'PASS' : 'FAIL'; + document.body.dataset.metrics = JSON.stringify({ + viewport: { + top: viewport.top, + bottom: viewport.bottom, + height: viewport.height, + scrollTop: viewportEl.scrollTop, + }, + panel: { + ...rect(panelEl), + cssHeight: getComputedStyle(panelEl).height, + minHeight: getComputedStyle(panelEl).minHeight, + }, + chat: { + ...rect(chatEl), + cssHeight: getComputedStyle(chatEl).height, + minHeight: getComputedStyle(chatEl).minHeight, + }, + scroll: { + ...rect(scrollEl), + cssHeight: getComputedStyle(scrollEl).height, + minHeight: getComputedStyle(scrollEl).minHeight, + overflowY: getComputedStyle(scrollEl).overflowY, + scrollHeight: scrollEl.scrollHeight, + scrollTop: scrollEl.scrollTop, + }, + input: rect(inputEl), + }); +}, 100))); diff --git a/tests/browser/ai-chat-input-layout/run.sh b/tests/browser/ai-chat-input-layout/run.sh new file mode 100755 index 000000000..2c69a7322 --- /dev/null +++ b/tests/browser/ai-chat-input-layout/run.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Browser regression for the completed-response layout in DocumentAIChatPanel. +# The textarea must remain inside the fixed-height sidebar before and after the +# first response becomes long enough to overflow the message viewport. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +PORT="${AI_CHAT_LAYOUT_TEST_PORT:-41973}" +HOOK_DIR="$ROOT/apps/hook" +TEST_DIR="$ROOT/tests/browser/ai-chat-input-layout" +TMP_HTML="$HOOK_DIR/.ai-chat-layout-test.html" +TMP_TSX="$HOOK_DIR/.ai-chat-layout-test.tsx" +SERVER_LOG="$(mktemp)" +BROWSER_OUTPUT="" + +find_chrome() { + if [[ -n "${CHROME_BIN:-}" && -x "$CHROME_BIN" ]]; then printf '%s' "$CHROME_BIN"; return; fi + local mac_chrome="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + if [[ -x "$mac_chrome" ]]; then printf '%s' "$mac_chrome"; return; fi + local candidate + for candidate in google-chrome chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1; then command -v "$candidate"; return; fi + done + return 1 +} + +CHROME="$(find_chrome || true)" +if [[ -z "$CHROME" ]]; then + echo "Chrome/Chromium not found (set CHROME_BIN to run this browser regression)." >&2 + exit 1 +fi + +cleanup() { + [[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true + rm -f "$TMP_HTML" "$TMP_TSX" "$SERVER_LOG" + if [[ -n "$BROWSER_OUTPUT" ]]; then + rm -f "$BROWSER_OUTPUT" + fi +} +trap cleanup EXIT +cp "$TEST_DIR/fixture.html" "$TMP_HTML" +cp "$TEST_DIR/fixture.tsx" "$TMP_TSX" + +bun run --cwd "$HOOK_DIR" vite --host 127.0.0.1 --port "$PORT" --strictPort >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! +for _ in {1..100}; do + if curl -sf "http://127.0.0.1:$PORT/.ai-chat-layout-test.html" >/dev/null; then break; fi + sleep 0.05 +done +if ! curl -sf "http://127.0.0.1:$PORT/.ai-chat-layout-test.html" >/dev/null; then + cat "$SERVER_LOG" >&2 + exit 1 +fi + +check_state() { + local state="$1" + local result metrics + BROWSER_OUTPUT="$(mktemp)" + "$CHROME" --headless=new --disable-gpu --no-sandbox --hide-scrollbars \ + --window-size=400,700 --virtual-time-budget=1500 --dump-dom \ + "http://127.0.0.1:$PORT/.ai-chat-layout-test.html?state=$state" >"$BROWSER_OUTPUT" 2>/dev/null + result="$(grep -o 'data-verdict="[A-Z]*"' "$BROWSER_OUTPUT" | head -1 || true)" + metrics="$(grep -o 'data-metrics="[^"]*"' "$BROWSER_OUTPUT" | head -1 || true)" + rm -f "$BROWSER_OUTPUT" + BROWSER_OUTPUT="" + echo "$state: $result $metrics" + [[ "$result" == 'data-verdict="PASS"' ]] +} + +check_state streaming +check_state completed From 39506a964bf397fdff5c65a29b9840302facfb4a Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Fri, 10 Jul 2026 09:51:39 -0400 Subject: [PATCH 2/2] chore: remove Ask AI browser harness --- .../browser/ai-chat-input-layout/fixture.html | 12 -- .../browser/ai-chat-input-layout/fixture.tsx | 128 ------------------ tests/browser/ai-chat-input-layout/run.sh | 71 ---------- 3 files changed, 211 deletions(-) delete mode 100644 tests/browser/ai-chat-input-layout/fixture.html delete mode 100644 tests/browser/ai-chat-input-layout/fixture.tsx delete mode 100755 tests/browser/ai-chat-input-layout/run.sh diff --git a/tests/browser/ai-chat-input-layout/fixture.html b/tests/browser/ai-chat-input-layout/fixture.html deleted file mode 100644 index fc54a3e5e..000000000 --- a/tests/browser/ai-chat-input-layout/fixture.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - AI chat input layout regression - - -
- - - diff --git a/tests/browser/ai-chat-input-layout/fixture.tsx b/tests/browser/ai-chat-input-layout/fixture.tsx deleted file mode 100644 index 6fe35081f..000000000 --- a/tests/browser/ai-chat-input-layout/fixture.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import '@plannotator/editor/styles'; -import { DocumentAIChatPanel } from '@plannotator/ui/components/ai/DocumentAIChatPanel'; -import type { AIChatEntry } from '@plannotator/ui/hooks/useAIChat'; -import type { AIProviderOption } from '@plannotator/ui/utils/aiProvider'; - -const completed = new URLSearchParams(location.search).get('state') !== 'streaming'; -const longResponse = Array.from( - { length: 8 }, - (_, i) => `Paragraph ${i + 1}: This completed response is long enough to overflow the chat viewport and trigger automatic scrolling.`, -).join('\n\n'); -const messages: AIChatEntry[] = [ - { - question: { - id: 'q1', - prompt: 'Can we use the actual codec API?', - createdAt: Date.now(), - scope: { - kind: 'selection', - label: 'Selected text', - text: 'export function codec() {}', - }, - }, - response: { - text: completed ? longResponse : '', - isStreaming: !completed, - }, - }, -]; -const providers: AIProviderOption[] = [ - { - id: 'pi-sdk', - name: 'Pi', - available: true, - models: [{ id: 'gpt-5.5', label: 'GPT-5.5', default: true }], - }, -]; - -function requireElement(selector: string, type: new () => T): T { - const element = document.querySelector(selector); - if (!(element instanceof type)) { - throw new Error(`Expected ${selector} to be a ${type.name}`); - } - return element; -} - -function rect(element: Element) { - const { top, bottom, height } = element.getBoundingClientRect(); - return { top, bottom, height }; -} - -function App() { - return ( -
- -
- ); -} - -const root = requireElement('#root', HTMLElement); -createRoot(root).render(); -requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(() => { - const viewportEl = requireElement('#viewport', HTMLElement); - const panelEl = requireElement('#panel', HTMLElement); - const chatEl = panelEl.lastElementChild; - if (!(chatEl instanceof HTMLElement)) { - throw new Error('Expected the chat panel to render as the final sidebar child'); - } - const scrollEl = chatEl.firstElementChild; - if (!(scrollEl instanceof HTMLElement)) { - throw new Error('Expected the chat panel to render a scroll viewport'); - } - const inputEl = requireElement('textarea', HTMLTextAreaElement); - const viewport = viewportEl.getBoundingClientRect(); - const input = inputEl.getBoundingClientRect(); - const visible = input.top >= viewport.top - && input.bottom <= viewport.bottom - && input.height > 0; - - document.body.dataset.verdict = visible ? 'PASS' : 'FAIL'; - document.body.dataset.metrics = JSON.stringify({ - viewport: { - top: viewport.top, - bottom: viewport.bottom, - height: viewport.height, - scrollTop: viewportEl.scrollTop, - }, - panel: { - ...rect(panelEl), - cssHeight: getComputedStyle(panelEl).height, - minHeight: getComputedStyle(panelEl).minHeight, - }, - chat: { - ...rect(chatEl), - cssHeight: getComputedStyle(chatEl).height, - minHeight: getComputedStyle(chatEl).minHeight, - }, - scroll: { - ...rect(scrollEl), - cssHeight: getComputedStyle(scrollEl).height, - minHeight: getComputedStyle(scrollEl).minHeight, - overflowY: getComputedStyle(scrollEl).overflowY, - scrollHeight: scrollEl.scrollHeight, - scrollTop: scrollEl.scrollTop, - }, - input: rect(inputEl), - }); -}, 100))); diff --git a/tests/browser/ai-chat-input-layout/run.sh b/tests/browser/ai-chat-input-layout/run.sh deleted file mode 100755 index 2c69a7322..000000000 --- a/tests/browser/ai-chat-input-layout/run.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Browser regression for the completed-response layout in DocumentAIChatPanel. -# The textarea must remain inside the fixed-height sidebar before and after the -# first response becomes long enough to overflow the message viewport. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -PORT="${AI_CHAT_LAYOUT_TEST_PORT:-41973}" -HOOK_DIR="$ROOT/apps/hook" -TEST_DIR="$ROOT/tests/browser/ai-chat-input-layout" -TMP_HTML="$HOOK_DIR/.ai-chat-layout-test.html" -TMP_TSX="$HOOK_DIR/.ai-chat-layout-test.tsx" -SERVER_LOG="$(mktemp)" -BROWSER_OUTPUT="" - -find_chrome() { - if [[ -n "${CHROME_BIN:-}" && -x "$CHROME_BIN" ]]; then printf '%s' "$CHROME_BIN"; return; fi - local mac_chrome="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - if [[ -x "$mac_chrome" ]]; then printf '%s' "$mac_chrome"; return; fi - local candidate - for candidate in google-chrome chromium chromium-browser; do - if command -v "$candidate" >/dev/null 2>&1; then command -v "$candidate"; return; fi - done - return 1 -} - -CHROME="$(find_chrome || true)" -if [[ -z "$CHROME" ]]; then - echo "Chrome/Chromium not found (set CHROME_BIN to run this browser regression)." >&2 - exit 1 -fi - -cleanup() { - [[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true - rm -f "$TMP_HTML" "$TMP_TSX" "$SERVER_LOG" - if [[ -n "$BROWSER_OUTPUT" ]]; then - rm -f "$BROWSER_OUTPUT" - fi -} -trap cleanup EXIT -cp "$TEST_DIR/fixture.html" "$TMP_HTML" -cp "$TEST_DIR/fixture.tsx" "$TMP_TSX" - -bun run --cwd "$HOOK_DIR" vite --host 127.0.0.1 --port "$PORT" --strictPort >"$SERVER_LOG" 2>&1 & -SERVER_PID=$! -for _ in {1..100}; do - if curl -sf "http://127.0.0.1:$PORT/.ai-chat-layout-test.html" >/dev/null; then break; fi - sleep 0.05 -done -if ! curl -sf "http://127.0.0.1:$PORT/.ai-chat-layout-test.html" >/dev/null; then - cat "$SERVER_LOG" >&2 - exit 1 -fi - -check_state() { - local state="$1" - local result metrics - BROWSER_OUTPUT="$(mktemp)" - "$CHROME" --headless=new --disable-gpu --no-sandbox --hide-scrollbars \ - --window-size=400,700 --virtual-time-budget=1500 --dump-dom \ - "http://127.0.0.1:$PORT/.ai-chat-layout-test.html?state=$state" >"$BROWSER_OUTPUT" 2>/dev/null - result="$(grep -o 'data-verdict="[A-Z]*"' "$BROWSER_OUTPUT" | head -1 || true)" - metrics="$(grep -o 'data-metrics="[^"]*"' "$BROWSER_OUTPUT" | head -1 || true)" - rm -f "$BROWSER_OUTPUT" - BROWSER_OUTPUT="" - echo "$state: $result $metrics" - [[ "$result" == 'data-verdict="PASS"' ]] -} - -check_state streaming -check_state completed