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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# 0.1.7

## What's new

## Fixes

* Fix input box input location jumping on chinese character input or dragging.

## Changes

* Remember input box size when switching chats
* Remember scroll location when switching chats

# 0.1.6

## What's new
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tinywebui-webapp",
"version": "0.1.6",
"version": "0.1.7",
"private": true,
"type": "module",
"scripts": {
Expand Down
26 changes: 24 additions & 2 deletions src/app/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ interface ChatProps {
selectedModelId?: string;
titleGenerationModelId?: string;
initialUserMessage?: ServerTypes.Message;
inputHeight: number;
onInputHeightChange: (height: number) => void;
initialScrollPosition?: number;
onScrollPositionChange?: (scrollTop: number) => void;
}

export function Chat({
Expand All @@ -26,11 +30,16 @@ export function Chat({
activeChatId,
selectedModelId,
titleGenerationModelId,
initialUserMessage
initialUserMessage,
inputHeight,
onInputHeightChange,
initialScrollPosition,
onScrollPositionChange,
}: ChatProps) {

const [generating, setGenerating] = useState(false);
const [loadingChat, setLoadingChat] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [treeHistory, setTreeHistory] = useState<ServerTypes.TreeHistory>({ nodes: {} });
const [tailNodeId, setTailNodeId] = useState<string | undefined>(undefined);
const [pendingUserMessage, setPendingUserMessage] = useState<ServerTypes.Message | undefined>(undefined);
Expand All @@ -44,6 +53,7 @@ export function Chat({
const initializationCalled = useRef(false);
const generatingCounter = useRef(0);
const initialGenerationScrollDone = useRef(false);
const scrollRestored = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);

Expand All @@ -54,7 +64,8 @@ export function Chat({
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setUserDetachedFromBottom(distanceFromBottom > 20);
}, []);
onScrollPositionChange?.(container.scrollTop);
}, [onScrollPositionChange]);

useEffect(() => {
if (!generating || !bottomRef.current) {
Expand Down Expand Up @@ -253,6 +264,7 @@ export function Chat({
onUserMessage(initialUserMessage);
/** generate chat title concurrently */
generateChatTitleAsync(activeChatId, initialUserMessage);
setInitialLoadComplete(true);
return;
}
/** Existing chat */
Expand All @@ -276,11 +288,19 @@ export function Chat({
}
} finally {
setLoadingChat(false);
setInitialLoadComplete(true);
}
})();
/** Only load once */
}, []);

useEffect(() => {
if (initialLoadComplete && !scrollRestored.current && initialScrollPosition !== undefined && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = initialScrollPosition;
scrollRestored.current = true;
}
}, [initialLoadComplete, initialScrollPosition]);

const getLinearHistory = useCallback(() : ServerTypes.MessageNode[] => {
const nodes: ServerTypes.MessageNode[] = [];
let id = tailNodeId;
Expand Down Expand Up @@ -470,6 +490,8 @@ export function Chat({
onUserMessage={onUserMessage}
inputEnabled={!loadingChat && !generating && generationError === undefined}
initialMessage={editingBranch ? messageToEdit : undefined}
editorHeight={inputHeight}
onEditorHeightChange={onInputHeightChange}
/>
</div>
);
Expand Down
12 changes: 12 additions & 0 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ export default function ChatPage() {
const [chatList, setChatList] = useState<ServerTypes.GetChatListResult>([]);
const [initialized, setInitialized] = useState(false);
const [newChatUserMessage, setNewChatUserMessage] = useState<ServerTypes.Message|undefined>(undefined);
const [inputHeight, setInputHeight] = useState<number>(80);
/** The index of the last chat displayed. -1 if none is displayed */
const maxDisplayedChatIndex = useRef<number>(-1);
const updateChatListPromise = useRef<Promise<void>|undefined>(undefined);
const scrollPositions = useRef<Record<string, number>>({});

const onSwitchChat = useCallback((chatId: string | undefined) => {
setActiveChatId(chatId);
Expand Down Expand Up @@ -70,6 +72,12 @@ export default function ChatPage() {
});
}, []);

const onScrollPositionChange = useCallback((scrollTop: number) => {
if (activeChatId) {
scrollPositions.current[activeChatId] = scrollTop;
}
}, [activeChatId]);

const updateChatListAsync = useCallback(async (fromStart?: boolean) => {
/** Allow two trials in case of resource conflict */
for (let trial = 0; trial < 2; trial++) {
Expand Down Expand Up @@ -187,6 +195,10 @@ export default function ChatPage() {
selectedModelId={selectedModelId}
titleGenerationModelId={titleGenerationModelId}
initialUserMessage={newChatUserMessage}
inputHeight={inputHeight}
onInputHeightChange={setInputHeight}
initialScrollPosition={activeChatId ? scrollPositions.current[activeChatId] : undefined}
onScrollPositionChange={onScrollPositionChange}
/>
</div>
</div>
Expand Down
52 changes: 40 additions & 12 deletions src/app/chat/user-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ interface UserInputProps {
/** This controls the send button. Not the editor. */
inputEnabled: boolean;
initialMessage?: ServerTypes.Message;
/** Optional controlled height for the editor. */
editorHeight?: number;
onEditorHeightChange?: (height: number) => void;
}

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

const [editorHeight, setEditorHeight] = React.useState<number>(MIN_HEIGHT);
const [uncontrolledEditorHeight, setUncontrolledEditorHeight] = React.useState<number>(MIN_HEIGHT);
const startYRef = React.useRef<number | null>(null);
const startHeightRef = React.useRef<number>(0);
const draggingRef = React.useRef(false);
const textAreaRef = React.useRef<HTMLTextAreaElement | null>(null);
const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);

const isControlled = controlledHeight !== undefined;
const editorHeight = isControlled ? controlledHeight as number : uncontrolledEditorHeight;
const setEditorHeight = React.useCallback((height: number) => {
const clamped = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, height));
if (isControlled) {
onEditorHeightChange?.(clamped);
} else {
setUncontrolledEditorHeight(clamped);
}
}, [isControlled, onEditorHeightChange]);

const beginDrag = (e: React.MouseEvent) => {
startYRef.current = e.clientY;
Expand Down Expand Up @@ -81,18 +96,30 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage }: UserI
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [editorHeight]);
}, [editorHeight, setEditorHeight]);

React.useEffect(() => {
React.useLayoutEffect(() => {
const ta = textAreaRef.current;
const parent = ta?.parentElement;
if (ta && parent) {
const childRect = ta.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const maxVisibleHeight = parentRect.height - (childRect.top - parentRect.top) - 8;
ta.style.height = "auto";
const targetHeight = Math.max(ta.scrollHeight, maxVisibleHeight);
ta.style.height = `${targetHeight}px`;
const scrollEl = scrollContainerRef.current;
if (!ta || !scrollEl) return;

const wasAtBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 8;
const previousScrollTop = scrollEl.scrollTop;

ta.style.height = "auto";

const parentRect = scrollEl.getBoundingClientRect();
const childRect = ta.getBoundingClientRect();
const maxVisibleHeight = parentRect.height - (childRect.top - parentRect.top) - 8;
const targetHeight = Math.max(ta.scrollHeight, maxVisibleHeight);

ta.style.height = `${targetHeight}px`;

// Restore scroll to where the user was, or keep the caret visible at the bottom.
if (wasAtBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
} else {
scrollEl.scrollTop = previousScrollTop;
}
}, [editorHeight, inputValue, imageUrls]);

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