From f4d6478f1ce1e6b4886bc35bfd79ee2f2eb9aefd Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 18 May 2026 15:09:08 +0800 Subject: [PATCH] feat(chat): add scroll-to-latest button (#1031) --- src/i18n/locales/en/chat.json | 1 + src/i18n/locales/ja/chat.json | 1 + src/i18n/locales/ru/chat.json | 1 + src/i18n/locales/zh/chat.json | 1 + src/pages/Chat/index.tsx | 22 ++++- tests/e2e/chat-scroll-to-latest.spec.ts | 91 +++++++++++++++++++ tests/unit/chat-page-execution-graph.test.tsx | 52 ++++++++++- 7 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/chat-scroll-to-latest.spec.ts diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index f91d623..0b1e771 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -14,6 +14,7 @@ "brainstorming": "Multi-Agent Parallel" }, "noLogs": "(No logs available yet)", + "scrollToLatest": "Jump to latest", "toolbar": { "refresh": "Refresh chat", "currentAgent": "Talking to {{agent}}", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 15e31bd..b3e081f 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -14,6 +14,7 @@ "brainstorming": "マルチエージェント並列実行" }, "noLogs": "(ログはまだありません)", + "scrollToLatest": "最新の会話へ移動", "toolbar": { "refresh": "チャットを更新", "currentAgent": "現在の会話相手: {{agent}}", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 98f4501..1696224 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -14,6 +14,7 @@ "brainstorming": "Параллельные агенты" }, "noLogs": "(Журналы ещё недоступны)", + "scrollToLatest": "К последним сообщениям", "toolbar": { "refresh": "Обновить чат", "currentAgent": "Общение с {{agent}}", diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 794af23..8679b45 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -14,6 +14,7 @@ "brainstorming": "多智能体并行" }, "noLogs": "(暂无日志)", + "scrollToLatest": "跳转到最新对话", "toolbar": { "refresh": "刷新聊天", "currentAgent": "当前对话对象:{{agent}}", diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index de38c02..94453e7 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -5,7 +5,7 @@ * are in the toolbar; messages render with markdown + streaming. */ import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, Loader2, Sparkles } from 'lucide-react'; +import { AlertCircle, ArrowDownToLine, Loader2, Sparkles } from 'lucide-react'; import { useChatStore, type RawMessage } from '@/stores/chat'; import { buildBaselineRunKey, getBaseline } from '@/stores/baseline-cache'; import { useGatewayStore } from '@/stores/gateway'; @@ -171,7 +171,7 @@ export function Chat() { const [graphExpandedOverrides, setGraphExpandedOverrides] = useState>({}); const graphStepCache: Record = graphStepCacheStore.get(currentSessionKey) ?? {}; const minLoading = useMinLoading(loading && messages.length > 0); - const { contentRef, scrollRef } = useStickToBottomInstant(currentSessionKey); + const { contentRef, scrollRef, scrollToBottom, isAtBottom } = useStickToBottomInstant(currentSessionKey); // Load data when gateway is running. // When the store already holds messages for this session (i.e. the user @@ -277,6 +277,7 @@ export function Chat() { const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus; const isEmpty = messages.length === 0 && !sending; + const showScrollToLatest = !isEmpty && !isAtBottom; const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message)); // Build an index of the *next* real user message after each position. // Gateway history may contain `role: 'user'` messages that are actually @@ -698,9 +699,9 @@ export function Chat() { {/* Messages Area */} -
+
-
+
+ {showScrollToLatest && ( + + )}
diff --git a/tests/e2e/chat-scroll-to-latest.spec.ts b/tests/e2e/chat-scroll-to-latest.spec.ts new file mode 100644 index 0000000..46c77ce --- /dev/null +++ b/tests/e2e/chat-scroll-to-latest.spec.ts @@ -0,0 +1,91 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const SESSION_KEY = 'agent:main:main'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +const seededHistory = Array.from({ length: 36 }, (_, idx) => ({ + role: idx % 2 === 0 ? 'user' : 'assistant', + content: `${idx === 0 ? 'Very first message' : 'Chat history message'} ${idx + 1}`, + timestamp: Date.now() + idx, +})); + +test.describe('ClawX chat scroll-to-latest affordance', () => { + test('shows a jump button when reading older messages and returns to the latest message', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345 }, + gatewayRpc: { + [stableStringify(['sessions.list', {}])]: { + success: true, + result: { + sessions: [{ key: SESSION_KEY, displayName: 'main' }], + }, + }, + [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: { + success: true, + result: { messages: seededHistory }, + }, + [stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: { + success: true, + result: { messages: seededHistory }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345 }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'main' }] }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByText('Chat history message 36')).toBeVisible({ timeout: 30_000 }); + + const scrollContainer = page.getByTestId('chat-scroll-container'); + await scrollContainer.evaluate((element) => { + element.scrollTop = 0; + element.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + const jumpButton = page.getByTestId('chat-scroll-to-latest'); + await expect(jumpButton).toBeVisible(); + await jumpButton.click(); + + await expect(jumpButton).toBeHidden({ timeout: 10_000 }); + await expect(page.getByText('Chat history message 36')).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx index b7735d8..7b88d56 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-page-execution-graph.test.tsx @@ -27,7 +27,8 @@ vi.mock('@/lib/host-api', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, params?: Record) => { + t: (key: string, params?: Record | string) => { + if (typeof params === 'string') return params; if (key === 'executionGraph.collapsedSummary') { return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); } @@ -52,10 +53,12 @@ vi.mock('react-i18next', () => ({ })); vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({ - useStickToBottomInstant: () => ({ + useStickToBottomInstant: vi.fn(() => ({ contentRef: { current: null }, scrollRef: { current: null }, - }), + scrollToBottom: vi.fn(), + isAtBottom: true, + })), })); vi.mock('@/hooks/use-min-loading', () => ({ @@ -258,6 +261,49 @@ describe('Chat execution graph lifecycle', () => { expect(screen.getByText('-1')).toBeInTheDocument(); }); + it('shows a scroll-to-latest button when the chat is scrolled away from the bottom', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: Array.from({ length: 24 }, (_, idx) => ({ + role: idx % 2 === 0 ? 'user' : 'assistant', + content: `Message ${idx + 1}`, + timestamp: Date.now() + idx, + })), + loading: false, + error: null, + runError: null, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { useStickToBottomInstant } = await import('@/hooks/use-stick-to-bottom-instant'); + vi.mocked(useStickToBottomInstant).mockReturnValue({ + contentRef: { current: null }, + scrollRef: { current: null }, + scrollToBottom: vi.fn(), + isAtBottom: false, + } as ReturnType); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + expect(await screen.findByTestId('chat-scroll-to-latest')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '跳转到最新对话' })).toBeInTheDocument(); + }); + it('stops showing trailing thinking and renders run error callout after terminal model error', async () => { const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({