feat(scroll): implement instant scroll-to-bottom behavior for chat messages (#438)
This commit is contained in:
committed by
GitHub
Unverified
parent
c0c8701cc3
commit
c0a3903377
@@ -51,10 +51,10 @@
|
|||||||
"clawhub": "^0.5.0",
|
"clawhub": "^0.5.0",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
"posthog-node": "^5.28.0",
|
"posthog-node": "^5.28.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0"
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"use-stick-to-bottom": "^1.1.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-electron": "^0.29.0",
|
"vite-plugin-electron": "^0.29.0",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6",
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -195,6 +195,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
use-stick-to-bottom:
|
||||||
|
specifier: ^1.1.3
|
||||||
|
version: 1.1.3(react@19.2.4)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.3.1
|
specifier: ^7.3.1
|
||||||
version: 7.3.1(@types/node@25.3.0)(jiti@1.21.7)(yaml@2.8.2)
|
version: 7.3.1(@types/node@25.3.0)(jiti@1.21.7)(yaml@2.8.2)
|
||||||
@@ -6373,6 +6376,11 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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:
|
use-sync-external-store@1.6.0:
|
||||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -14192,6 +14200,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@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):
|
use-sync-external-store@1.6.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|||||||
45
src/hooks/use-min-loading.ts
Normal file
45
src/hooks/use-min-loading.ts
Normal file
@@ -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<number>(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;
|
||||||
|
}
|
||||||
58
src/hooks/use-stick-to-bottom-instant.ts
Normal file
58
src/hooks/use-stick-to-bottom-instant.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
|
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
|
||||||
* are in the toolbar; messages render with markdown + streaming.
|
* 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 { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
@@ -16,6 +16,8 @@ import { ChatToolbar } from './ChatToolbar';
|
|||||||
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
|
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
|
||||||
|
import { useMinLoading } from '@/hooks/use-min-loading';
|
||||||
|
|
||||||
export function Chat() {
|
export function Chat() {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
@@ -23,6 +25,7 @@ export function Chat() {
|
|||||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||||
|
|
||||||
const messages = useChatStore((s) => s.messages);
|
const messages = useChatStore((s) => s.messages);
|
||||||
|
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
||||||
const loading = useChatStore((s) => s.loading);
|
const loading = useChatStore((s) => s.loading);
|
||||||
const sending = useChatStore((s) => s.sending);
|
const sending = useChatStore((s) => s.sending);
|
||||||
const error = useChatStore((s) => s.error);
|
const error = useChatStore((s) => s.error);
|
||||||
@@ -37,8 +40,9 @@ export function Chat() {
|
|||||||
|
|
||||||
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
|
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
|
||||||
|
const minLoading = useMinLoading(loading && messages.length > 0);
|
||||||
|
const { contentRef, scrollRef } = useStickToBottomInstant(currentSessionKey);
|
||||||
|
|
||||||
// Load data when gateway is running.
|
// Load data when gateway is running.
|
||||||
// When the store already holds messages for this session (i.e. the user
|
// When the store already holds messages for this session (i.e. the user
|
||||||
@@ -57,11 +61,6 @@ export function Chat() {
|
|||||||
void fetchAgents();
|
void fetchAgents();
|
||||||
}, [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
|
// Update timestamp when sending starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sending && streamingTimestamp === 0) {
|
if (sending && streamingTimestamp === 0) {
|
||||||
@@ -89,23 +88,19 @@ export function Chat() {
|
|||||||
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||||
const hasAnyStreamContent = 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 (
|
return (
|
||||||
<div className={cn("flex flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
<div className={cn("relative flex flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
||||||
<ChatToolbar />
|
<ChatToolbar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
<div className="max-w-4xl mx-auto space-y-4">
|
<div ref={contentRef} className="max-w-4xl mx-auto space-y-4">
|
||||||
{loading && !sending ? (
|
{isEmpty ? (
|
||||||
<div className="flex h-[60vh] items-center justify-center">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
</div>
|
|
||||||
) : isEmpty ? (
|
|
||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -149,9 +144,6 @@ export function Chat() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scroll anchor */}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,6 +173,15 @@ export function Chat() {
|
|||||||
sending={sending}
|
sending={sending}
|
||||||
isEmpty={isEmpty}
|
isEmpty={isEmpty}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Transparent loading overlay */}
|
||||||
|
{minLoading && !sending && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/20 backdrop-blur-[1px] rounded-xl pointer-events-auto">
|
||||||
|
<div className="bg-background shadow-lg rounded-full p-2.5 border border-border">
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,10 +221,10 @@ function WelcomeScreen() {
|
|||||||
function TypingIndicator() {
|
function TypingIndicator() {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted rounded-2xl px-4 py-3">
|
<div className="bg-black/5 dark:bg-white/5 text-foreground rounded-2xl px-4 py-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
@@ -240,10 +241,10 @@ function ActivityIndicator({ phase }: { phase: 'tool_processing' }) {
|
|||||||
void phase;
|
void phase;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted rounded-2xl px-4 py-3">
|
<div className="bg-black/5 dark:bg-white/5 text-foreground rounded-2xl px-4 py-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
<span>Processing tool results…</span>
|
<span>Processing tool results…</span>
|
||||||
|
|||||||
@@ -1158,6 +1158,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// ── Switch session ──
|
// ── Switch session ──
|
||||||
|
|
||||||
switchSession: (key: string) => {
|
switchSession: (key: string) => {
|
||||||
|
if (key === get().currentSessionKey) return;
|
||||||
set((s) => buildSessionSwitchPatch(s, key));
|
set((s) => buildSessionSwitchPatch(s, key));
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user