From c0a3903377e3b7dd93d4f8caa1de6096989ee796 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:02:43 +0800 Subject: [PATCH] feat(scroll): implement instant scroll-to-bottom behavior for chat messages (#438) --- package.json | 5 +- pnpm-lock.yaml | 12 +++++ src/hooks/use-min-loading.ts | 45 ++++++++++++++++++ src/hooks/use-stick-to-bottom-instant.ts | 58 ++++++++++++++++++++++++ src/pages/Chat/index.tsx | 47 +++++++++---------- src/stores/chat.ts | 1 + 6 files changed, 143 insertions(+), 25 deletions(-) create mode 100644 src/hooks/use-min-loading.ts create mode 100644 src/hooks/use-stick-to-bottom-instant.ts diff --git a/package.json b/package.json index a602d0020..486cd774d 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,10 @@ "clawhub": "^0.5.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", + "ms": "^2.1.3", "node-machine-id": "^1.1.12", "posthog-node": "^5.28.0", - "ws": "^8.19.0", - "ms": "^2.1.3" + "ws": "^8.19.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -111,6 +111,7 @@ "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", + "use-stick-to-bottom": "^1.1.3", "vite": "^7.3.1", "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 341c5c0ce..87d585ded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + use-stick-to-bottom: + specifier: ^1.1.3 + version: 1.1.3(react@19.2.4) vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.3.0)(jiti@1.21.7)(yaml@2.8.2) @@ -6373,6 +6376,11 @@ packages: '@types/react': optional: true + use-stick-to-bottom@1.1.3: + resolution: {integrity: sha512-GgRLdeGhxBxpcbrBbEIEoOKUQ9d46/eaSII+wyv1r9Du+NbCn1W/OE+VddefvRP4+5w/1kATN/6g2/BAC/yowQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -14192,6 +14200,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-stick-to-bottom@1.1.3(react@19.2.4): + dependencies: + react: 19.2.4 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/hooks/use-min-loading.ts b/src/hooks/use-min-loading.ts new file mode 100644 index 000000000..9a3ac3021 --- /dev/null +++ b/src/hooks/use-min-loading.ts @@ -0,0 +1,45 @@ +import { useState, useRef, useEffect } from 'react'; + +/** + * A hook that ensures a loading state remains true for at least a minimum duration (e.g., 1000ms), + * preventing flickering for very fast loading times. + * + * @param isLoading - The actual loading state from the data source + * @param minDurationMs - Minimum duration to show loading (default: 1000) + */ +export function useMinLoading(isLoading: boolean, minDurationMs: number = 500) { + const [showLoading, setShowLoading] = useState(isLoading); + const startTime = useRef(0); + + // Guarantee that the loading UI activates immediately without any asynchronous delay + if (isLoading && !showLoading) { + setShowLoading(true); + } + + // Record the actual timestamp in an effect to respect React purity rules + useEffect(() => { + if (isLoading && startTime.current === 0) { + startTime.current = Date.now(); + } + }, [isLoading]); + + useEffect(() => { + let timeout: NodeJS.Timeout; + + if (!isLoading && showLoading) { + const elapsed = startTime.current > 0 ? Date.now() - startTime.current : 0; + const remaining = Math.max(0, minDurationMs - elapsed); + + timeout = setTimeout(() => { + setShowLoading(false); + startTime.current = 0; + }, remaining); + } + + return () => { + if (timeout) clearTimeout(timeout); + }; + }, [isLoading, showLoading, minDurationMs]); + + return isLoading || showLoading; +} diff --git a/src/hooks/use-stick-to-bottom-instant.ts b/src/hooks/use-stick-to-bottom-instant.ts new file mode 100644 index 000000000..c572261eb --- /dev/null +++ b/src/hooks/use-stick-to-bottom-instant.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import { useStickToBottom } from "use-stick-to-bottom"; + +/** + * A wrapper around useStickToBottom that ensures the initial scroll + * to bottom happens instantly without any visible animation. + * + * @param resetKey - When this key changes, the scroll position will be reset to bottom instantly. + * Typically this should be the conversation ID. + */ +export function useStickToBottomInstant(resetKey?: string) { + const lastKeyRef = useRef(resetKey); + const hasInitializedRef = useRef(false); + + const result = useStickToBottom({ + initial: "instant", + resize: "smooth", + }); + + const { scrollRef } = result; + + // Reset initialization when key changes + useEffect(() => { + if (resetKey !== lastKeyRef.current) { + hasInitializedRef.current = false; + lastKeyRef.current = resetKey; + } + }, [resetKey]); + + // Scroll to bottom instantly on mount or when key changes + useEffect(() => { + if (hasInitializedRef.current) return; + + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + // Hide, scroll, reveal pattern to avoid visible animation + scrollElement.style.visibility = "hidden"; + + // Use double RAF to ensure content is rendered + const frame1 = requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Direct scroll to bottom + scrollElement.scrollTop = scrollElement.scrollHeight; + + // Small delay to ensure scroll is applied + setTimeout(() => { + scrollElement.style.visibility = ""; + hasInitializedRef.current = true; + }, 0); + }); + }); + + return () => cancelAnimationFrame(frame1); + }, [scrollRef, resetKey]); + + return result; +} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 468368243..78dd82add 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -4,7 +4,7 @@ * via gateway:rpc IPC. Session selector, thinking toggle, and refresh * are in the toolbar; messages render with markdown + streaming. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { AlertCircle, Loader2, Sparkles } from 'lucide-react'; import { useChatStore, type RawMessage } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; @@ -16,6 +16,8 @@ import { ChatToolbar } from './ChatToolbar'; import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant'; +import { useMinLoading } from '@/hooks/use-min-loading'; export function Chat() { const { t } = useTranslation('chat'); @@ -23,6 +25,7 @@ export function Chat() { const isGatewayRunning = gatewayStatus.state === 'running'; const messages = useChatStore((s) => s.messages); + const currentSessionKey = useChatStore((s) => s.currentSessionKey); const loading = useChatStore((s) => s.loading); const sending = useChatStore((s) => s.sending); const error = useChatStore((s) => s.error); @@ -37,8 +40,9 @@ export function Chat() { const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession); - const messagesEndRef = useRef(null); const [streamingTimestamp, setStreamingTimestamp] = useState(0); + const minLoading = useMinLoading(loading && messages.length > 0); + const { contentRef, scrollRef } = useStickToBottomInstant(currentSessionKey); // Load data when gateway is running. // When the store already holds messages for this session (i.e. the user @@ -57,11 +61,6 @@ export function Chat() { void fetchAgents(); }, [fetchAgents]); - // Auto-scroll on new messages, streaming, or activity changes - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, streamingMessage, sending, pendingFinal]); - // Update timestamp when sending starts useEffect(() => { if (sending && streamingTimestamp === 0) { @@ -89,23 +88,19 @@ export function Chat() { const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus); const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus; - const isEmpty = messages.length === 0 && !loading && !sending; + const isEmpty = messages.length === 0 && !sending; return ( -
+
{/* Toolbar */}
{/* Messages Area */} -
-
- {loading && !sending ? ( -
- -
- ) : isEmpty ? ( +
+
+ {isEmpty ? ( ) : ( <> @@ -149,9 +144,6 @@ export function Chat() { )} )} - - {/* Scroll anchor */} -
@@ -181,6 +173,15 @@ export function Chat() { sending={sending} isEmpty={isEmpty} /> + + {/* Transparent loading overlay */} + {minLoading && !sending && ( +
+
+ +
+
+ )}
); } @@ -220,10 +221,10 @@ function WelcomeScreen() { function TypingIndicator() { return (
-
+
-
+
@@ -240,10 +241,10 @@ function ActivityIndicator({ phase }: { phase: 'tool_processing' }) { void phase; return (
-
+
-
+
Processing tool results… diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 9f61d97e9..726a95ed1 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1158,6 +1158,7 @@ export const useChatStore = create((set, get) => ({ // ── Switch session ── switchSession: (key: string) => { + if (key === get().currentSessionKey) return; set((s) => buildSessionSwitchPatch(s, key)); get().loadHistory(); },