Skip to content

Commit fa9efef

Browse files
committed
Implement responsive auto-sizing chat textarea
Replaces the manual textarea resizing with an automatic height adjustment based on content. - `useChatTextarea` hook to manage textarea state and auto-sizing logic via refs, preserving the optimization - Textarea now grows vertically up to a maximum height (`lg:max-h-48`) on large screens (lg breakpoint and up). - Disables auto-sizing and enables manual vertical resizing (`resize-vertical`) on smaller screens for better mobile usability. - Aligns the "Send" button to the bottom of the textarea (`items-end`) for consistent positioning during resize.
1 parent b1ebaf5 commit fa9efef

File tree

3 files changed

+137
-101
lines changed

3 files changed

+137
-101
lines changed

examples/server/webui/src/components/ChatScreen.tsx

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { classNames, cleanCurrentUrl, throttle } from '../utils/misc';
66
import CanvasPyInterpreter from './CanvasPyInterpreter';
77
import StorageUtils from '../utils/storage';
88
import { useVSCodeContext } from '../utils/llama-vscode';
9-
import { useAutosizeTextarea, AutosizeTextareaApi } from './useAutosizeTextarea.ts';
9+
import {
10+
useChatTextarea,
11+
AutosizeTextareaApi,
12+
} from './useChatTextarea.ts';
1013

1114

1215
/**
@@ -102,7 +105,10 @@ export default function ChatScreen() {
102105
canvasData,
103106
replaceMessageAndGenerate,
104107
} = useAppContext();
105-
const textarea: AutosizeTextareaApi = useAutosizeTextarea(prefilledMsg.content());
108+
109+
const textarea: AutosizeTextareaApi = useChatTextarea(
110+
prefilledMsg.content()
111+
);
106112

107113
const { extraContext, clearExtraContext } = useVSCodeContext(textarea);
108114
// TODO: improve this when we have "upload file" feature
@@ -251,37 +257,42 @@ export default function ChatScreen() {
251257
</div>
252258

253259
{/* chat input */}
254-
<div className="flex flex-row items-start pt-8 pb-6 sticky bottom-0 bg-base-100">
255-
<textarea
256-
className="textarea textarea-bordered w-full resize-none max-h-48 overflow-y-auto"
257-
placeholder="Type a message (Shift+Enter to add a new line)"
258-
ref={textarea.ref}
259-
onInput={textarea.onInput}
260-
onKeyDown={(e) => {
261-
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
262-
if (e.key === 'Enter' && !e.shiftKey) {
263-
e.preventDefault();
264-
sendNewMessage();
265-
}
266-
}}
267-
id="msg-input"
268-
dir="auto"
269-
rows={1}
270-
></textarea>
271-
{isGenerating(currConvId ?? '') ? (
272-
<button
273-
className="btn btn-neutral ml-2"
274-
onClick={() => stopGenerating(currConvId ?? '')}
275-
>
276-
Stop
277-
</button>
278-
) : (
279-
<button className="btn btn-primary ml-2" onClick={sendNewMessage}>
280-
Send
281-
</button>
282-
)}
283-
</div>
284-
</div>
260+
<div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100">
261+
<textarea
262+
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
263+
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
264+
className="textarea textarea-bordered w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60)
265+
placeholder="Type a message (Shift+Enter to add a new line)"
266+
ref={textarea.ref}
267+
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
268+
onKeyDown={(e) => {
269+
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
270+
if (e.key === 'Enter' && !e.shiftKey) {
271+
e.preventDefault();
272+
sendNewMessage();
273+
}
274+
}}
275+
id="msg-input"
276+
dir="auto"
277+
// Set a base height of 2 rows for mobile views
278+
// On lg+ screens, the hook will calculate and set the initial height anyway
279+
rows={2}
280+
></textarea>
281+
282+
{isGenerating(currConvId ?? '') ? (
283+
<button
284+
className="btn btn-neutral ml-2"
285+
onClick={() => stopGenerating(currConvId ?? '')}
286+
>
287+
Stop
288+
</button>
289+
) : (
290+
<button className="btn btn-primary ml-2" onClick={sendNewMessage}>
291+
Send
292+
</button>
293+
)}
294+
</div>
295+
</div>
285296
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
286297
{canvasData?.type === CanvasType.PY_INTERPRETER && (
287298
<CanvasPyInterpreter />

examples/server/webui/src/components/useAutosizeTextarea.ts

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useEffect, useRef, useState, useCallback } from 'react';
2+
3+
// Media Query for detecting "large" screens (matching Tailwind's lg: breakpoint)
4+
const LARGE_SCREEN_MQ = '(min-width: 1024px)';
5+
6+
// Calculates and sets the textarea height based on its scrollHeight
7+
const adjustTextareaHeight = (textarea: HTMLTextAreaElement | null) => {
8+
if (!textarea) return;
9+
10+
// Only perform auto-sizing on large screens
11+
if (!window.matchMedia(LARGE_SCREEN_MQ).matches) {
12+
// On small screens, reset inline height and max-height styles.
13+
// This allows CSS (e.g., `rows` attribute or classes) to control the height,
14+
// and enables manual resizing if `resize-vertical` is set.
15+
textarea.style.height = ''; // Use 'auto' or '' to reset
16+
textarea.style.maxHeight = '';
17+
return; // Do not adjust height programmatically on small screens
18+
}
19+
20+
const computedStyle = window.getComputedStyle(textarea);
21+
// Get the max-height specified by CSS (e.g., from `lg:max-h-48`)
22+
const currentMaxHeight = computedStyle.maxHeight;
23+
24+
// Temporarily remove max-height to allow scrollHeight to be calculated correctly
25+
textarea.style.maxHeight = 'none';
26+
// Reset height to 'auto' to measure the actual scrollHeight needed
27+
textarea.style.height = 'auto';
28+
// Set the height to the calculated scrollHeight
29+
textarea.style.height = `${textarea.scrollHeight}px`;
30+
// Re-apply the original max-height from CSS to enforce the limit
31+
textarea.style.maxHeight = currentMaxHeight;
32+
};
33+
34+
// Interface describing the API returned by the hook
35+
export interface AutosizeTextareaApi {
36+
value: () => string;
37+
setValue: (value: string) => void;
38+
focus: () => void;
39+
ref: React.RefObject<HTMLTextAreaElement>;
40+
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void; // Input handler
41+
}
42+
43+
// This is a workaround to prevent the textarea from re-rendering when the inner content changes
44+
// See https://github.com/ggml-org/llama.cpp/pull/12299
45+
// combined now with auto-sizing logic.
46+
export function useChatTextarea(initValue: string): AutosizeTextareaApi {
47+
const [savedInitValue, setSavedInitValue] = useState<string>(initValue);
48+
const textareaRef = useRef<HTMLTextAreaElement>(null);
49+
50+
// Effect to set initial value and height on mount or when initValue changes
51+
useEffect(() => {
52+
const textarea = textareaRef.current;
53+
if (textarea) {
54+
if (typeof savedInitValue === 'string' && savedInitValue.length > 0) {
55+
textarea.value = savedInitValue;
56+
// Call adjustTextareaHeight - it will check screen size internally
57+
setTimeout(() => adjustTextareaHeight(textarea), 0);
58+
setSavedInitValue(''); // Reset after applying
59+
} else {
60+
// Adjust height even if there's no initial value (for initial render)
61+
setTimeout(() => adjustTextareaHeight(textarea), 0);
62+
}
63+
}
64+
}, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue
65+
66+
const handleInput = useCallback((event: React.FormEvent<HTMLTextAreaElement>) => {
67+
// Call adjustTextareaHeight on every input - it will decide whether to act
68+
adjustTextareaHeight(event.currentTarget);
69+
}, []);
70+
71+
return {
72+
// Method to get the current value directly from the textarea
73+
value: () => {
74+
return textareaRef.current?.value ?? '';
75+
},
76+
// Method to programmatically set the value and trigger height adjustment
77+
setValue: (value: string) => {
78+
const textarea = textareaRef.current;
79+
if (textarea) {
80+
textarea.value = value;
81+
// Call adjustTextareaHeight - it will check screen size internally
82+
setTimeout(() => adjustTextareaHeight(textarea), 0);
83+
}
84+
},
85+
focus: () => {
86+
if (textareaRef.current) {
87+
textareaRef.current.focus();
88+
}
89+
},
90+
ref: textareaRef,
91+
onInput: handleInput,
92+
};
93+
}

0 commit comments

Comments
 (0)