Skip to content

Commit 8ca6e1c

Browse files
characharmngxson
andauthored
server : webui : Improve Chat Input with Auto-Sizing Textarea (#12785)
* Update ChatScreen.tsx * useAutosizeTextarea.ts useAutosizeTextarea to encapsulate the logic. * 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. * -update compressed index.html.gz after npm run build -refactor: replace OptimizedTextareaValue with AutosizeTextareaApi in VSCode context hook * chore: normalize line endings to LF refactor: AutosizeTextareaApi -> chatTextareaApi * refactor: Rename interface to PascalCase --------- Co-authored-by: Xuan Son Nguyen <[email protected]>
1 parent 656babd commit 8ca6e1c

File tree

4 files changed

+111
-47
lines changed

4 files changed

+111
-47
lines changed

examples/server/public/index.html.gz

252 Bytes
Binary file not shown.

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

+13-45
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useEffect, useMemo, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
33
import ChatMessage from './ChatMessage';
44
import { CanvasType, Message, PendingMessage } from '../utils/types';
55
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 { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts';
910

1011
/**
1112
* A message display is a message node with additional information for rendering.
@@ -99,7 +100,8 @@ export default function ChatScreen() {
99100
canvasData,
100101
replaceMessageAndGenerate,
101102
} = useAppContext();
102-
const textarea = useOptimizedTextarea(prefilledMsg.content());
103+
104+
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
103105

104106
const { extraContext, clearExtraContext } = useVSCodeContext(textarea);
105107
// TODO: improve this when we have "upload file" feature
@@ -248,22 +250,28 @@ export default function ChatScreen() {
248250
</div>
249251

250252
{/* chat input */}
251-
<div className="flex flex-row items-center pt-8 pb-6 sticky bottom-0 bg-base-100">
253+
<div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100">
252254
<textarea
253-
className="textarea textarea-bordered w-full"
255+
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
256+
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
257+
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)
254258
placeholder="Type a message (Shift+Enter to add a new line)"
255259
ref={textarea.ref}
260+
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
256261
onKeyDown={(e) => {
257262
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
258-
if (e.key === 'Enter' && e.shiftKey) return;
259263
if (e.key === 'Enter' && !e.shiftKey) {
260264
e.preventDefault();
261265
sendNewMessage();
262266
}
263267
}}
264268
id="msg-input"
265269
dir="auto"
270+
// Set a base height of 2 rows for mobile views
271+
// On lg+ screens, the hook will calculate and set the initial height anyway
272+
rows={2}
266273
></textarea>
274+
267275
{isGenerating(currConvId ?? '') ? (
268276
<button
269277
className="btn btn-neutral ml-2"
@@ -286,43 +294,3 @@ export default function ChatScreen() {
286294
</div>
287295
);
288296
}
289-
290-
export interface OptimizedTextareaValue {
291-
value: () => string;
292-
setValue: (value: string) => void;
293-
focus: () => void;
294-
ref: React.RefObject<HTMLTextAreaElement>;
295-
}
296-
297-
// This is a workaround to prevent the textarea from re-rendering when the inner content changes
298-
// See https://github.com/ggml-org/llama.cpp/pull/12299
299-
function useOptimizedTextarea(initValue: string): OptimizedTextareaValue {
300-
const [savedInitValue, setSavedInitValue] = useState<string>(initValue);
301-
const textareaRef = useRef<HTMLTextAreaElement>(null);
302-
303-
useEffect(() => {
304-
if (textareaRef.current && savedInitValue) {
305-
textareaRef.current.value = savedInitValue;
306-
setSavedInitValue('');
307-
}
308-
}, [textareaRef, savedInitValue, setSavedInitValue]);
309-
310-
return {
311-
value: () => {
312-
return textareaRef.current?.value ?? savedInitValue;
313-
},
314-
setValue: (value: string) => {
315-
if (textareaRef.current) {
316-
textareaRef.current.value = value;
317-
}
318-
},
319-
focus: () => {
320-
if (textareaRef.current) {
321-
// focus and move the cursor to the end
322-
textareaRef.current.focus();
323-
textareaRef.current.selectionStart = textareaRef.current.value.length;
324-
}
325-
},
326-
ref: textareaRef,
327-
};
328-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 ChatTextareaApi {
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): ChatTextareaApi {
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(
67+
(event: React.FormEvent<HTMLTextAreaElement>) => {
68+
// Call adjustTextareaHeight on every input - it will decide whether to act
69+
adjustTextareaHeight(event.currentTarget);
70+
},
71+
[]
72+
);
73+
74+
return {
75+
// Method to get the current value directly from the textarea
76+
value: () => {
77+
return textareaRef.current?.value ?? '';
78+
},
79+
// Method to programmatically set the value and trigger height adjustment
80+
setValue: (value: string) => {
81+
const textarea = textareaRef.current;
82+
if (textarea) {
83+
textarea.value = value;
84+
// Call adjustTextareaHeight - it will check screen size internally
85+
setTimeout(() => adjustTextareaHeight(textarea), 0);
86+
}
87+
},
88+
focus: () => {
89+
if (textareaRef.current) {
90+
textareaRef.current.focus();
91+
}
92+
},
93+
ref: textareaRef,
94+
onInput: handleInput,
95+
};
96+
}

examples/server/webui/src/utils/llama-vscode.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useState } from 'react';
22
import { MessageExtraContext } from './types';
3-
import { OptimizedTextareaValue } from '../components/ChatScreen';
3+
import { ChatTextareaApi } from '../components/useChatTextarea.ts';
44

55
// Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe
66
// Ref: https://github.com/ggml-org/llama.cpp/pull/11940
@@ -15,7 +15,7 @@ interface SetTextEvData {
1515
* window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n return 123' }, '*');
1616
*/
1717

18-
export const useVSCodeContext = (textarea: OptimizedTextareaValue) => {
18+
export const useVSCodeContext = (textarea: ChatTextareaApi) => {
1919
const [extraContext, setExtraContext] = useState<MessageExtraContext | null>(
2020
null
2121
);

0 commit comments

Comments
 (0)