From 73e6deb6b0d13c5b54174d677ee9106534f477e0 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 11:55:45 +0000 Subject: [PATCH 1/3] IMPLEMENT: auto-grow PromptInput up to 8 lines (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the textarea in ChatPanel not growing as the user types multi-line prompts. The auto-grow useEffect had an empty dependency array so it only fired at mount; changing the dep to [input] makes it recalculate height on every keystroke. Also removed the conflicting max-h-32 Tailwind class (replaced by the inline-style cap) and replaced the hardcoded 160px ceiling with a computed value of lineHeight×8 + vertical padding so the limit correctly represents 8 lines regardless of font metrics. Files changed: - apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx - apps/desktop/tests/components/chat/ChatPanel.test.tsx --- .../src/components/chat/ChatPanel.tsx | 14 +++++--- .../tests/components/chat/ChatPanel.test.tsx | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx index 05708d3..71038c2 100644 --- a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx +++ b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx @@ -166,14 +166,18 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - // Auto-grow the textarea up to a cap so prompts with a few lines - // don't force the user to scroll a single-line input. + // Auto-grow the textarea up to 8 lines; content scrolls beyond that. + // biome-ignore lint/correctness/useExhaustiveDependencies: input is the intended trigger; the effect reads the DOM via textareaRef useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; - el.style.height = `${Math.min(el.scrollHeight, 160)}px`; - }, []); + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; + const paddingTop = parseFloat(getComputedStyle(el).paddingTop) || 0; + const paddingBottom = parseFloat(getComputedStyle(el).paddingBottom) || 0; + const maxHeight = lineHeight * 8 + paddingTop + paddingBottom; + el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; + }, [input]); const fileThreads = useMemo( () => history.threadsForFile(filePath), @@ -377,7 +381,7 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { className={cn( 'w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm', 'placeholder:text-muted-foreground focus:outline-none', - 'disabled:cursor-not-allowed max-h-32 overflow-y-auto', + 'disabled:cursor-not-allowed overflow-y-auto', )} data-testid="chat-input" /> diff --git a/apps/desktop/tests/components/chat/ChatPanel.test.tsx b/apps/desktop/tests/components/chat/ChatPanel.test.tsx index c5d23d7..1a7b485 100644 --- a/apps/desktop/tests/components/chat/ChatPanel.test.tsx +++ b/apps/desktop/tests/components/chat/ChatPanel.test.tsx @@ -111,4 +111,40 @@ describe('ChatPanel', () => { expect(screen.getByTitle('Stop')).toBeInTheDocument(); expect(screen.queryByTitle('Send')).not.toBeInTheDocument(); }); + + it('auto-grows textarea height as input increases', async () => { + render(); + await waitFor(() => { + const ta = screen.getByTestId('chat-input') as HTMLTextAreaElement; + expect(ta.disabled).toBe(false); + }); + const textarea = screen.getByTestId('chat-input') as HTMLTextAreaElement; + + // Simulate a tall scrollHeight (multi-line content) and verify the + // inline style is updated to match it when input changes. + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 80 }); + fireEvent.change(textarea, { target: { value: 'line1\nline2\nline3' } }); + expect(textarea.style.height).toBe('80px'); + }); + + it('caps textarea height at 8 lines and keeps overflow-y-auto for scrolling', async () => { + render(); + await waitFor(() => { + const ta = screen.getByTestId('chat-input') as HTMLTextAreaElement; + expect(ta.disabled).toBe(false); + }); + const textarea = screen.getByTestId('chat-input') as HTMLTextAreaElement; + + // Simulate scrollHeight exceeding 8 lines. + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 9999 }); + fireEvent.change(textarea, { target: { value: 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj' } }); + + // The inline height must be capped (not 9999px). + const heightPx = parseFloat(textarea.style.height); + expect(heightPx).toBeLessThan(9999); + expect(heightPx).toBeGreaterThan(0); + + // The textarea must still scroll beyond the cap. + expect(textarea.className).toMatch(/overflow-y-auto/); + }); }); From c81dc0f8d704831b1b46001d9bb6da137bf47116 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 11:59:37 +0000 Subject: [PATCH 2/3] REVIEW: fix broken import alias and add height-reset edge case test Fix `./gh` typo in eligibility.test.ts (no such file; only github.ts exists). The import type was silently erased at runtime so tests passed, but tsc caught the error. Add a test asserting the textarea height shrinks back when input is cleared. --- .sandcastle/eligibility.test.ts | 2 +- .../tests/components/chat/ChatPanel.test.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.sandcastle/eligibility.test.ts b/.sandcastle/eligibility.test.ts index bb53d4e..d8e1602 100644 --- a/.sandcastle/eligibility.test.ts +++ b/.sandcastle/eligibility.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { evaluate, pickEligible } from "./eligibility"; -import type { IssueSnapshot, OpenPRClosing } from "./gh"; +import type { IssueSnapshot, OpenPRClosing } from "./github"; const cfg = { label: "Sandcastle" }; diff --git a/apps/desktop/tests/components/chat/ChatPanel.test.tsx b/apps/desktop/tests/components/chat/ChatPanel.test.tsx index 1a7b485..2653ef2 100644 --- a/apps/desktop/tests/components/chat/ChatPanel.test.tsx +++ b/apps/desktop/tests/components/chat/ChatPanel.test.tsx @@ -147,4 +147,22 @@ describe('ChatPanel', () => { // The textarea must still scroll beyond the cap. expect(textarea.className).toMatch(/overflow-y-auto/); }); + + it('resets textarea height when input is cleared', async () => { + render(); + await waitFor(() => { + const ta = screen.getByTestId('chat-input') as HTMLTextAreaElement; + expect(ta.disabled).toBe(false); + }); + const textarea = screen.getByTestId('chat-input') as HTMLTextAreaElement; + + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 80 }); + fireEvent.change(textarea, { target: { value: 'line1\nline2\nline3' } }); + expect(textarea.style.height).toBe('80px'); + + // Clearing input should shrink the textarea back down. + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 20 }); + fireEvent.change(textarea, { target: { value: '' } }); + expect(textarea.style.height).toBe('20px'); + }); }); From bfa98665b9090c23ad848a87715c6819178253b8 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 13:35:04 +0100 Subject: [PATCH 3/3] refactor(chat): replace useEffect auto-grow with CSS field-sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the JS-driven textarea height effect and biome-ignore in favour of the CSS `field-sizing: content` property paired with a `max-h` cap and `overflow-y-auto`. Tailwind v4 ships the `field-sizing-content` utility and Electron 35 (Chromium 134) supports the property natively. Also collapse the scroll-on-new-message effect to depend on `messages.length` (extracted to a const so biome no longer flags the dependency) instead of suppressing useExhaustiveDependencies. Tests: replace JSDOM scrollHeight/style.height assertions with className contract checks — height is now a CSS concern, not observable behaviour in JSDOM. Add a CODING_STANDARDS rule banning biome-ignore / @ts-ignore / eslint-disable across the codebase. --- .sandcastle/CODING_STANDARDS.md | 1 + .../src/components/chat/ChatPanel.tsx | 23 ++------- .../tests/components/chat/ChatPanel.test.tsx | 50 +++---------------- 3 files changed, 12 insertions(+), 62 deletions(-) diff --git a/.sandcastle/CODING_STANDARDS.md b/.sandcastle/CODING_STANDARDS.md index d0d8345..04026cf 100644 --- a/.sandcastle/CODING_STANDARDS.md +++ b/.sandcastle/CODING_STANDARDS.md @@ -13,6 +13,7 @@ - No `any` — ever (`noExplicitAny` is an error) - kebab-case filenames for components; camelCase for utilities - 2-space indent, 100-char line width, single quotes, trailing commas (Biome enforced) +- **Never** suppress lint rules with `biome-ignore` (or `@ts-ignore`/`eslint-disable`). If a rule fires, fix the underlying code — restructure the effect, derive a value, lift state, or pick a different approach. A suppression silences the linter but leaves the smell; future readers can't tell whether it's still warranted. The same applies to `as any` and other type escape hatches. ## Architecture diff --git a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx index 71038c2..c36ab7d 100644 --- a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx +++ b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx @@ -73,7 +73,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { const history = useChatThreads(); const [input, setInput] = useState(''); - const textareaRef = useRef(null); const messagesEndRef = useRef(null); // Track previous filePath so file-change and initial-mount paths @@ -161,23 +160,11 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { }); }, [history.activeThreadId, history.updateThreadSessionId]); - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever the message list changes; biome can't see `messages` is the intended trigger + const messageCount = messages.length; useEffect(() => { + if (messageCount === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - // Auto-grow the textarea up to 8 lines; content scrolls beyond that. - // biome-ignore lint/correctness/useExhaustiveDependencies: input is the intended trigger; the effect reads the DOM via textareaRef - useEffect(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = 'auto'; - const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; - const paddingTop = parseFloat(getComputedStyle(el).paddingTop) || 0; - const paddingBottom = parseFloat(getComputedStyle(el).paddingBottom) || 0; - const maxHeight = lineHeight * 8 + paddingTop + paddingBottom; - el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; - }, [input]); + }, [messageCount]); const fileThreads = useMemo( () => history.threadsForFile(filePath), @@ -371,7 +358,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { )} >