Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .sandcastle/CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 6 additions & 15 deletions apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
const history = useChatThreads();

const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);

// Track previous filePath so file-change and initial-mount paths
Expand Down Expand Up @@ -161,19 +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 a cap so prompts with a few lines
// don't force the user to scroll a single-line input.
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, []);
}, [messageCount]);

const fileThreads = useMemo(
() => history.threadsForFile(filePath),
Expand Down Expand Up @@ -367,17 +358,17 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
)}
>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
placeholder={isReady ? 'Ask anything…' : 'Configure auth first…'}
disabled={!isReady || isStreaming}
rows={1}
className={cn(
'w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm',
'w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm leading-5',
'field-sizing-content max-h-[calc(8*1.25rem+1.25rem)]',
'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"
/>
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/tests/components/chat/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,20 @@ describe('ChatPanel', () => {
expect(screen.getByTitle('Stop')).toBeInTheDocument();
expect(screen.queryByTitle('Send')).not.toBeInTheDocument();
});

it('auto-grows the textarea via CSS field-sizing with an 8-line cap and scroll overflow', async () => {
render(<ChatPanel chat={makeChat()} />);
await waitFor(() => {
const ta = screen.getByTestId('chat-input') as HTMLTextAreaElement;
expect(ta.disabled).toBe(false);
});
const textarea = screen.getByTestId('chat-input') as HTMLTextAreaElement;

// Auto-grow is delivered by CSS (`field-sizing: content`) rather
// than a useEffect that pokes inline height — verify the contract
// is expressed on the element.
expect(textarea.className).toMatch(/field-sizing-content/);
expect(textarea.className).toMatch(/max-h-\[/);
expect(textarea.className).toMatch(/overflow-y-auto/);
});
});
Loading