Skip to content

Commit 49266b5

Browse files
authored
Fix chat input bugs (#35)
* fix input jumping * keep input height when switching chat * remember scroll location when switching chats * update version and change log
1 parent c7731b8 commit 49266b5

File tree

5 files changed

+90
-15
lines changed

5 files changed

+90
-15
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# 0.1.7
2+
3+
## What's new
4+
5+
## Fixes
6+
7+
* Fix input box input location jumping on chinese character input or dragging.
8+
9+
## Changes
10+
11+
* Remember input box size when switching chats
12+
* Remember scroll location when switching chats
13+
114
# 0.1.6
215

316
## What's new

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tinywebui-webapp",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"private": true,
55
"type": "module",
66
"scripts": {

src/app/chat/chat.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ interface ChatProps {
1717
selectedModelId?: string;
1818
titleGenerationModelId?: string;
1919
initialUserMessage?: ServerTypes.Message;
20+
inputHeight: number;
21+
onInputHeightChange: (height: number) => void;
22+
initialScrollPosition?: number;
23+
onScrollPositionChange?: (scrollTop: number) => void;
2024
}
2125

2226
export function Chat({
@@ -26,11 +30,16 @@ export function Chat({
2630
activeChatId,
2731
selectedModelId,
2832
titleGenerationModelId,
29-
initialUserMessage
33+
initialUserMessage,
34+
inputHeight,
35+
onInputHeightChange,
36+
initialScrollPosition,
37+
onScrollPositionChange,
3038
}: ChatProps) {
3139

3240
const [generating, setGenerating] = useState(false);
3341
const [loadingChat, setLoadingChat] = useState(false);
42+
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
3443
const [treeHistory, setTreeHistory] = useState<ServerTypes.TreeHistory>({ nodes: {} });
3544
const [tailNodeId, setTailNodeId] = useState<string | undefined>(undefined);
3645
const [pendingUserMessage, setPendingUserMessage] = useState<ServerTypes.Message | undefined>(undefined);
@@ -44,6 +53,7 @@ export function Chat({
4453
const initializationCalled = useRef(false);
4554
const generatingCounter = useRef(0);
4655
const initialGenerationScrollDone = useRef(false);
56+
const scrollRestored = useRef(false);
4757
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
4858
const bottomRef = useRef<HTMLDivElement | null>(null);
4959

@@ -54,7 +64,8 @@ export function Chat({
5464
}
5565
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
5666
setUserDetachedFromBottom(distanceFromBottom > 20);
57-
}, []);
67+
onScrollPositionChange?.(container.scrollTop);
68+
}, [onScrollPositionChange]);
5869

5970
useEffect(() => {
6071
if (!generating || !bottomRef.current) {
@@ -253,6 +264,7 @@ export function Chat({
253264
onUserMessage(initialUserMessage);
254265
/** generate chat title concurrently */
255266
generateChatTitleAsync(activeChatId, initialUserMessage);
267+
setInitialLoadComplete(true);
256268
return;
257269
}
258270
/** Existing chat */
@@ -276,11 +288,19 @@ export function Chat({
276288
}
277289
} finally {
278290
setLoadingChat(false);
291+
setInitialLoadComplete(true);
279292
}
280293
})();
281294
/** Only load once */
282295
}, []);
283296

297+
useEffect(() => {
298+
if (initialLoadComplete && !scrollRestored.current && initialScrollPosition !== undefined && scrollContainerRef.current) {
299+
scrollContainerRef.current.scrollTop = initialScrollPosition;
300+
scrollRestored.current = true;
301+
}
302+
}, [initialLoadComplete, initialScrollPosition]);
303+
284304
const getLinearHistory = useCallback(() : ServerTypes.MessageNode[] => {
285305
const nodes: ServerTypes.MessageNode[] = [];
286306
let id = tailNodeId;
@@ -470,6 +490,8 @@ export function Chat({
470490
onUserMessage={onUserMessage}
471491
inputEnabled={!loadingChat && !generating && generationError === undefined}
472492
initialMessage={editingBranch ? messageToEdit : undefined}
493+
editorHeight={inputHeight}
494+
onEditorHeightChange={onInputHeightChange}
473495
/>
474496
</div>
475497
);

src/app/chat/page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ export default function ChatPage() {
2121
const [chatList, setChatList] = useState<ServerTypes.GetChatListResult>([]);
2222
const [initialized, setInitialized] = useState(false);
2323
const [newChatUserMessage, setNewChatUserMessage] = useState<ServerTypes.Message|undefined>(undefined);
24+
const [inputHeight, setInputHeight] = useState<number>(80);
2425
/** The index of the last chat displayed. -1 if none is displayed */
2526
const maxDisplayedChatIndex = useRef<number>(-1);
2627
const updateChatListPromise = useRef<Promise<void>|undefined>(undefined);
28+
const scrollPositions = useRef<Record<string, number>>({});
2729

2830
const onSwitchChat = useCallback((chatId: string | undefined) => {
2931
setActiveChatId(chatId);
@@ -70,6 +72,12 @@ export default function ChatPage() {
7072
});
7173
}, []);
7274

75+
const onScrollPositionChange = useCallback((scrollTop: number) => {
76+
if (activeChatId) {
77+
scrollPositions.current[activeChatId] = scrollTop;
78+
}
79+
}, [activeChatId]);
80+
7381
const updateChatListAsync = useCallback(async (fromStart?: boolean) => {
7482
/** Allow two trials in case of resource conflict */
7583
for (let trial = 0; trial < 2; trial++) {
@@ -187,6 +195,10 @@ export default function ChatPage() {
187195
selectedModelId={selectedModelId}
188196
titleGenerationModelId={titleGenerationModelId}
189197
initialUserMessage={newChatUserMessage}
198+
inputHeight={inputHeight}
199+
onInputHeightChange={setInputHeight}
200+
initialScrollPosition={activeChatId ? scrollPositions.current[activeChatId] : undefined}
201+
onScrollPositionChange={onScrollPositionChange}
190202
/>
191203
</div>
192204
</div>

src/app/chat/user-input.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ interface UserInputProps {
1212
/** This controls the send button. Not the editor. */
1313
inputEnabled: boolean;
1414
initialMessage?: ServerTypes.Message;
15+
/** Optional controlled height for the editor. */
16+
editorHeight?: number;
17+
onEditorHeightChange?: (height: number) => void;
1518
}
1619

17-
export function UserInput({ onUserMessage, inputEnabled, initialMessage }: UserInputProps) {
20+
export function UserInput({ onUserMessage, inputEnabled, initialMessage, editorHeight: controlledHeight, onEditorHeightChange }: UserInputProps) {
1821
/** Text input */
1922
const [inputValue, setInputValue] = React.useState(
2023
initialMessage?.content
@@ -31,11 +34,23 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage }: UserI
3134
const MIN_HEIGHT = 80;
3235
const MAX_HEIGHT = 400;
3336

34-
const [editorHeight, setEditorHeight] = React.useState<number>(MIN_HEIGHT);
37+
const [uncontrolledEditorHeight, setUncontrolledEditorHeight] = React.useState<number>(MIN_HEIGHT);
3538
const startYRef = React.useRef<number | null>(null);
3639
const startHeightRef = React.useRef<number>(0);
3740
const draggingRef = React.useRef(false);
3841
const textAreaRef = React.useRef<HTMLTextAreaElement | null>(null);
42+
const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);
43+
44+
const isControlled = controlledHeight !== undefined;
45+
const editorHeight = isControlled ? controlledHeight as number : uncontrolledEditorHeight;
46+
const setEditorHeight = React.useCallback((height: number) => {
47+
const clamped = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, height));
48+
if (isControlled) {
49+
onEditorHeightChange?.(clamped);
50+
} else {
51+
setUncontrolledEditorHeight(clamped);
52+
}
53+
}, [isControlled, onEditorHeightChange]);
3954

4055
const beginDrag = (e: React.MouseEvent) => {
4156
startYRef.current = e.clientY;
@@ -81,18 +96,30 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage }: UserI
8196
window.removeEventListener("mousemove", onMove);
8297
window.removeEventListener("mouseup", onUp);
8398
};
84-
}, [editorHeight]);
99+
}, [editorHeight, setEditorHeight]);
85100

86-
React.useEffect(() => {
101+
React.useLayoutEffect(() => {
87102
const ta = textAreaRef.current;
88-
const parent = ta?.parentElement;
89-
if (ta && parent) {
90-
const childRect = ta.getBoundingClientRect();
91-
const parentRect = parent.getBoundingClientRect();
92-
const maxVisibleHeight = parentRect.height - (childRect.top - parentRect.top) - 8;
93-
ta.style.height = "auto";
94-
const targetHeight = Math.max(ta.scrollHeight, maxVisibleHeight);
95-
ta.style.height = `${targetHeight}px`;
103+
const scrollEl = scrollContainerRef.current;
104+
if (!ta || !scrollEl) return;
105+
106+
const wasAtBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 8;
107+
const previousScrollTop = scrollEl.scrollTop;
108+
109+
ta.style.height = "auto";
110+
111+
const parentRect = scrollEl.getBoundingClientRect();
112+
const childRect = ta.getBoundingClientRect();
113+
const maxVisibleHeight = parentRect.height - (childRect.top - parentRect.top) - 8;
114+
const targetHeight = Math.max(ta.scrollHeight, maxVisibleHeight);
115+
116+
ta.style.height = `${targetHeight}px`;
117+
118+
// Restore scroll to where the user was, or keep the caret visible at the bottom.
119+
if (wasAtBottom) {
120+
scrollEl.scrollTop = scrollEl.scrollHeight;
121+
} else {
122+
scrollEl.scrollTop = previousScrollTop;
96123
}
97124
}, [editorHeight, inputValue, imageUrls]);
98125

@@ -184,6 +211,7 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage }: UserI
184211
"absolute inset-0 flex flex-col overflow-y-auto rounded-md border border-input bg-transparent",
185212
"scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
186213
)}
214+
ref={scrollContainerRef}
187215
>
188216
{imageUrls.length > 0 && (
189217
<div className="p-2 flex flex-wrap gap-2">

0 commit comments

Comments
 (0)