From 0dac40579b9d30f50692bd9e319d2eac23766d2f Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 12:59:25 +0800 Subject: [PATCH 1/3] fix(ui): preserve stream reading position and deduplicate live status --- ui/e2e/fixtures/streaming-scroll.html | 2 + ui/e2e/fixtures/streaming-scroll.jsx | 73 ++++++ ui/e2e/streaming-scroll.config.mjs | 18 ++ ui/e2e/streaming-scroll.spec.mjs | 105 ++++++++ ui/src/components/chat-v2/ChatInterfaceV2.tsx | 17 +- ui/src/components/chat-v2/MessageRowV2.tsx | 58 +---- .../chat-v2/MessagesPaneV2.render.test.tsx | 22 +- ui/src/components/chat-v2/MessagesPaneV2.tsx | 146 ++++++----- .../components/chat-v2/ProcessTrace.test.tsx | 10 +- ui/src/components/chat-v2/ProcessTrace.tsx | 48 +--- .../chat-v2/StreamingScrollViewport.tsx | 25 ++ .../SubagentDetailMessageFlow.render.test.tsx | 12 +- .../chat-v2/SubagentDetailMessageFlow.tsx | 69 ++--- ui/src/components/chat-v2/ThinkingBlock.tsx | 50 ++++ .../chat-v2/processGrouping.streaming.test.ts | 35 +++ .../chat-v2/processGrouping.test.ts | 8 +- ui/src/components/chat-v2/processGrouping.ts | 31 ++- .../chat-v2/useChatHistorySearch.ts | 45 +++- .../components/chat-v2/useTypewriter.test.tsx | 60 +++++ ui/src/components/chat-v2/useTypewriter.ts | 104 ++++---- .../chat/hooks/useChatComposerState.ts | 2 +- .../components/chat/hooks/useChatMessages.ts | 3 +- .../chat/hooks/useChatSessionState.spec.ts | 7 + .../chat/hooks/useChatSessionState.ts | 240 ++++++++---------- .../chat/hooks/useScrollFollow.test.tsx | 139 ++++++++++ .../components/chat/hooks/useScrollFollow.ts | 208 +++++++++++++++ ui/src/components/chat/types/types.ts | 1 + ui/src/components/chat/utils/messageKeys.ts | 1 + .../chat/view/subcomponents/Markdown.tsx | 4 +- ui/src/i18n/locales/en/chat.json | 3 + ui/src/i18n/locales/zh-CN/chat.json | 3 + .../useSessionStore.renderKeys.test.tsx | 44 ++++ ui/src/stores/useSessionStore.ts | 43 +++- 33 files changed, 1167 insertions(+), 469 deletions(-) create mode 100644 ui/e2e/fixtures/streaming-scroll.html create mode 100644 ui/e2e/fixtures/streaming-scroll.jsx create mode 100644 ui/e2e/streaming-scroll.config.mjs create mode 100644 ui/e2e/streaming-scroll.spec.mjs create mode 100644 ui/src/components/chat-v2/StreamingScrollViewport.tsx create mode 100644 ui/src/components/chat-v2/ThinkingBlock.tsx create mode 100644 ui/src/components/chat-v2/processGrouping.streaming.test.ts create mode 100644 ui/src/components/chat-v2/useTypewriter.test.tsx create mode 100644 ui/src/components/chat/hooks/useScrollFollow.test.tsx create mode 100644 ui/src/components/chat/hooks/useScrollFollow.ts create mode 100644 ui/src/stores/useSessionStore.renderKeys.test.tsx diff --git a/ui/e2e/fixtures/streaming-scroll.html b/ui/e2e/fixtures/streaming-scroll.html new file mode 100644 index 000000000..f79b89fc8 --- /dev/null +++ b/ui/e2e/fixtures/streaming-scroll.html @@ -0,0 +1,2 @@ + +Streaming scroll regression
diff --git a/ui/e2e/fixtures/streaming-scroll.jsx b/ui/e2e/fixtures/streaming-scroll.jsx new file mode 100644 index 000000000..67267e3d1 --- /dev/null +++ b/ui/e2e/fixtures/streaming-scroll.jsx @@ -0,0 +1,73 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { FindShortcutProvider } from '../../src/contexts/FindShortcutContext'; +import MessagesPane from '../../src/components/chat-v2/MessagesPaneV2'; +import { useScrollFollow } from '../../src/components/chat/hooks/useScrollFollow'; +import { normalizedToChatMessages } from '../../src/components/chat/hooks/useChatMessages'; +import { useSessionStore } from '../../src/stores/useSessionStore'; +import '../../src/index.css'; + +const sid = 'scroll-fixture'; +const timestamp = '2026-09-05T00:00:00.000Z'; +const base = { sessionId: sid, provider: 'pilotdeck', timestamp, runId: 'live' }; +function Fixture() { + const store = useSessionStore(); + const [working, setWorking] = useState(false); + const [inline, setInline] = useState(true); + const [mode, setMode] = useState('agent'); + const [olderMessages, setOlderMessages] = useState([]); + const ref = useRef(null); + const messages = [...olderMessages, ...normalizedToChatMessages(store.getMessages(sid))]; + const follow = useScrollFollow({ containerRef: ref, enabled: true, scopeKey: sid, contentKey: messages.length > 0, contentSelector: '[data-chat-scroll-content]' }); + useEffect(() => { + store.setActiveSession(sid); + store.appendRealtimeBatch(sid, Array.from({ length: 24 }, (_, index) => [ + { ...base, id: `user-${index}`, kind: 'text', role: 'user', content: `Question ${index}` }, + { ...base, id: `answer-${index}`, kind: 'text', role: 'assistant', content: `Answer ${index}: This is a historical response with enough text to read while a new response streams.\n\nSecond paragraph with a searchable needle ${index}.` }, + ]).flat()); + }, [store]); + window.streamFixture = { + prepend(count) { + setOlderMessages((previous) => [ + ...Array.from({ length: count }, (_, index) => ({ + id: `older-${previous.length + index}`, type: index % 2 ? 'assistant' : 'user', + content: `Earlier message ${previous.length + index}: additional history.`, timestamp, + })), ...previous, + ]); + }, + think(text) { + setWorking(true); + store.updateStreamingThinking(sid, text, 'pilotdeck', 'live'); + }, + tool(id = 'fetch-1') { + store.finalizeStreamingThinking(sid, 'live'); + store.appendRealtime(sid, { ...base, id, kind: 'tool_use', toolId: id, toolName: 'web_fetch', toolInput: { url: `https://example.com/${id}` } }); + setWorking(true); + }, + finishTool(id = 'fetch-1', content = '') { + store.appendRealtime(sid, { ...base, id: `${id}-result`, kind: 'tool_result', toolId: id, content, isError: false }); + }, + text(text) { setWorking(true); store.finalizeStreamingThinking(sid, 'live'); store.updateStreaming(sid, text, 'pilotdeck', 'live'); }, + complete() { store.finalizeStreamingThinking(sid, 'live'); store.finalizeStreaming(sid, 'live'); setWorking(false); }, + setInline, setMode, + }; + const diff = useMemo(() => () => [], []); + return
+ follow.setPaused(true)} + chatMessages={messages} visibleMessages={messages} visibleMessageCount={100} + isLoadingSessionMessages={false} isLoadingMoreMessages={false} + hasMoreMessages={false} totalMessages={messages.length} + loadEarlierMessages={() => {}} loadAllMessages={() => {}} + allMessagesLoaded={false} isLoadingAllMessages={false} + provider="pilotdeck" selectedProject={null} selectedSession={null} + createDiff={diff} showThinking inlineThinking={inline} setInput={() => {}} + isAssistantWorking={working} sessionRuntimeState={working ? 'running' : 'inactive'} + activeRunId="live" runMode={mode} planModeActive={mode === 'plan'} + /> +
; +} +createRoot(document.getElementById('root')).render(); diff --git a/ui/e2e/streaming-scroll.config.mjs b/ui/e2e/streaming-scroll.config.mjs new file mode 100644 index 000000000..c69e88e6e --- /dev/null +++ b/ui/e2e/streaming-scroll.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export default defineConfig({ + testDir: '.', + testMatch: 'streaming-scroll.spec.mjs', + outputDir: '/tmp/pilotdeck-stream-playwright', + workers: 1, + use: { baseURL: 'http://127.0.0.1:5179', viewport: { width: 1100, height: 800 }, screenshot: 'only-on-failure' }, + webServer: { + command: 'node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5179 --strictPort', + cwd: uiRoot, + url: 'http://127.0.0.1:5179/e2e/fixtures/streaming-scroll.html', + reuseExistingServer: false, + }, +}); diff --git a/ui/e2e/streaming-scroll.spec.mjs b/ui/e2e/streaming-scroll.spec.mjs new file mode 100644 index 000000000..485973ca6 --- /dev/null +++ b/ui/e2e/streaming-scroll.spec.mjs @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; + +const lines = (count) => Array.from({ length: count }, (_, i) => `Thinking line ${i + 1}: inspect and compare the implementation.`).join('\n\n'); +const viewport = (page) => page.locator('[data-chat-search-surface]'); +const top = (locator) => locator.evaluate((node) => node.scrollTop); +const distance = (locator) => locator.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop); + +async function open(page) { + await page.goto('/e2e/fixtures/streaming-scroll.html'); + await expect(page.getByText('Question 23', { exact: true })).toBeVisible(); + await expect.poll(() => distance(viewport(page))).toBeLessThan(3); +} + +test('small upward gestures win over text streaming and explicit resume follows again', async ({ page }) => { + await open(page); + await page.evaluate(() => window.streamFixture.text('Streaming response.\n\n'.repeat(60))); + await expect.poll(() => distance(viewport(page))).toBeLessThan(3); + await viewport(page).hover({ position: { x: 20, y: 300 } }); + await page.mouse.wheel(0, -30); + await expect(page.getByRole('button', { name: 'Back to latest' })).toBeVisible(); + const readingTop = await top(viewport(page)); + await page.evaluate(() => window.streamFixture.text('Streaming response.\n\n'.repeat(100))); + await expect.poll(() => distance(viewport(page))).toBeGreaterThan(40); + await page.waitForTimeout(700); + expect(Math.abs(await top(viewport(page)) - readingTop)).toBeLessThan(2); + await page.getByRole('button', { name: 'Back to latest' }).click(); + await expect.poll(() => distance(viewport(page))).toBeLessThan(3); +}); + +test('thinking retains its viewport, expansion and reading position through tools and completion', async ({ page }) => { + await open(page); + await page.evaluate((text) => window.streamFixture.think(text), lines(30)); + const thinking = page.getByRole('region', { name: 'Live thinking content' }); + await expect(thinking.getByText(/Thinking line 30/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Thinking...' })).toHaveCount(1); + await thinking.hover(); + await page.mouse.wheel(0, -70); + await expect.poll(() => distance(thinking)).toBeGreaterThan(30); + const readingTop = await top(thinking); + const outerTop = await top(viewport(page)); + await page.evaluate((text) => window.streamFixture.think(text), lines(45)); + await page.waitForTimeout(500); + expect(Math.abs(await top(thinking) - readingTop)).toBeLessThan(2); + await page.evaluate(() => window.streamFixture.tool()); + await expect(page.getByRole('button', { name: 'Thought process' })).toHaveAttribute('aria-expanded', 'true'); + expect(Math.abs(await top(thinking) - readingTop)).toBeLessThan(2); + expect(Math.abs(await top(viewport(page)) - outerTop)).toBeLessThan(2); + await page.evaluate(() => { window.streamFixture.finishTool(); window.streamFixture.text('Final answer.'); window.streamFixture.complete(); }); + await expect(thinking).toBeVisible(); + expect(Math.abs(await top(thinking) - readingTop)).toBeLessThan(2); + await page.screenshot({ path: test.info().outputPath('thinking-reading-position.png') }); +}); + +test('web fetch has one status in plan mode and empty results finish it; parallel calls remain distinct', async ({ page }) => { + await open(page); + await page.evaluate(() => { window.streamFixture.setMode('plan'); window.streamFixture.tool(); }); + await expect(page.locator('.process-live-status')).toHaveCount(1); + await expect(page.getByText('Fetching web content...', { exact: true })).toHaveCount(1); + await page.evaluate(() => window.streamFixture.finishTool()); + await expect(page.getByText('Fetching web content...', { exact: true })).toHaveCount(0); + await page.evaluate(() => { window.streamFixture.tool('fetch-2'); window.streamFixture.tool('fetch-3'); window.streamFixture.finishTool('fetch-2'); }); + await expect(page.getByText('Fetching web content...', { exact: true })).toHaveCount(1); + await page.evaluate(() => window.streamFixture.finishTool('fetch-3')); + await expect(page.getByText('Fetching web content...', { exact: true })).toHaveCount(0); +}); + +test('stream updates do not repeatedly recenter an existing search match', async ({ page }) => { + await open(page); + await viewport(page).click({ position: { x: 20, y: 300 } }); + await page.keyboard.press('Meta+f'); + await page.getByRole('searchbox').fill('needle 10'); + await expect(page.locator('mark.chat-history-search-highlight-active')).toBeVisible(); + await viewport(page).hover({ position: { x: 20, y: 300 } }); + await page.mouse.wheel(0, -120); + await page.waitForTimeout(150); + const readingTop = await top(viewport(page)); + await page.evaluate(() => window.streamFixture.text('New streamed content '.repeat(150))); + await page.waitForTimeout(700); + expect(Math.abs(await top(viewport(page)) - readingTop)).toBeLessThan(2); +}); + +test('prepending virtualized history while streaming preserves the visible message anchor', async ({ page }) => { + await open(page); + await page.evaluate(() => window.streamFixture.prepend(200)); + await expect(page.locator('[data-virtualized-messages="true"]')).toHaveCount(1); + await expect.poll(() => distance(viewport(page))).toBeLessThan(3); + await viewport(page).hover({ position: { x: 20, y: 300 } }); + await page.mouse.wheel(0, -300); + await expect(page.getByRole('button', { name: 'Back to latest' })).toBeVisible(); + await page.waitForTimeout(200); + const anchor = await viewport(page).evaluate((node) => { + const rect = node.getBoundingClientRect(); + const row = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > rect.top); + return { key: row.dataset.messageKey, y: row.getBoundingClientRect().top }; + }); + await page.evaluate(() => { window.streamFixture.prepend(60); window.streamFixture.text('New response.\n\n'.repeat(50)); }); + await expect.poll(() => viewport(page).evaluate((node, key) => { + const anchorRow = [...node.querySelectorAll('[data-message-key]')].find((item) => item.dataset.messageKey === key); + return anchorRow ? anchorRow.getBoundingClientRect().top : -10000; + }, anchor.key)).toBeCloseTo(anchor.y, 0); + await page.waitForTimeout(700); + const finalY = await viewport(page).evaluate((node, key) => [...node.querySelectorAll('[data-message-key]')] + .find((item) => item.dataset.messageKey === key)?.getBoundingClientRect().top, anchor.key); + expect(Math.abs(finalY - anchor.y)).toBeLessThan(2); +}); diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index eee5f14ed..4ca8fe865 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -195,6 +195,7 @@ function ChatInterfaceV2({ setCanAbortSession, isAborting: _isAborting, setIsAborting, + isUserScrolledUp, setIsUserScrolledUp, tokenBudget, setTokenBudget, @@ -211,7 +212,8 @@ function ChatInterfaceV2({ createDiff, scrollContainerRef, scrollToBottom, - handleScroll, + scheduleScrollToBottom, + pauseScrollFollowing, } = useChatSessionState({ selectedProject, selectedSession, @@ -501,11 +503,9 @@ function ChatInterfaceV2({ setInput(forkDraft); requestAnimationFrame(() => { textareaRef.current?.focus(); - scrollToBottom?.(); + scheduleScrollToBottom?.(); }); - // Messages load asynchronously after the session switch; scroll again - // once the carried history has had a chance to render. - setTimeout(() => scrollToBottom?.(), 400); + // The scroll controller follows when the carried history finishes loading. addToast( 'success', t('fork.ready', { @@ -525,7 +525,7 @@ function ChatInterfaceV2({ isLoading, sessionIsReadOnly, onNavigateToSession, - scrollToBottom, + scheduleScrollToBottom, selectedProject, selectedSession?.id, setInput, @@ -858,8 +858,9 @@ function ChatInterfaceV2({
formatUsageLimitText(String(message.content ?? '')), [message.content], ); - const thinkingDisplayText = useTypewriter(formattedContent, !!message.isStreaming && !!message.isThinking, 4); const contentDisplayText = useTypewriter(formattedContent, !!message.isStreaming && !message.isThinking, 6); const assistantArtifacts = useMemo( () => (Array.isArray(message.artifacts) ? message.artifacts : []), @@ -542,52 +542,14 @@ function MessageRowV2({ if (message.isThinking) { if (!showThinking) return null; - const isThinkingStreaming = !!message.isStreaming; - - if (inlineThinking) { - // Inline mode: unified
with typewriter animation + blue theme - return withProcessRows( -
-
12 : false) || undefined}> - - {isThinkingStreaming - ? - : } - - {isThinkingStreaming - ? t('thinking.title', { defaultValue: 'Thinking...' }) - : t('thinking.completed', { defaultValue: 'Thought process' })} - - -
- - {isThinkingStreaming ? thinkingDisplayText : formattedContent} - -
-
-
, - ); - } - - // Default (status-bar preview mode): simple collapsible accordion return withProcessRows( -
-
- - - {t('thinking.completed', { defaultValue: 'Thought process' })} - -
- {formattedContent} -
-
-
, + , ); } @@ -618,7 +580,7 @@ function MessageRowV2({ } : {})} > {contentDisplayText} + onFileOpen={onFileOpen} isStreaming={Boolean(message.isStreaming) || contentDisplayText !== formattedContent} artifactFiles={assistantArtifacts}>{contentDisplayText}
)} {assistantArtifacts.length > 0 ? ( diff --git a/ui/src/components/chat-v2/MessagesPaneV2.render.test.tsx b/ui/src/components/chat-v2/MessagesPaneV2.render.test.tsx index ce007dc48..1a24ca352 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.render.test.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.render.test.tsx @@ -119,8 +119,6 @@ function createPaneElement({ {}} - onTouchMove={() => {}} isLoadingSessionMessages={false} chatMessages={messages} activityMessages={activityMessages} @@ -178,8 +176,6 @@ function SessionPaneHarness({ {}} - onTouchMove={() => {}} isLoadingSessionMessages={false} chatMessages={messages} visibleMessages={messages} @@ -342,7 +338,7 @@ describe('MessagesPaneV2 render behavior', () => { expect(screen.getByRole('region', { name: 'Live thinking content' })).toBeTruthy(); }); - it('expands the reasoning window when switching away from inline thinking during a run', () => { + it('preserves the same reasoning viewport when switching display modes during a run', () => { const now = new Date().toISOString(); const messages: ChatMessage[] = [ { @@ -367,12 +363,13 @@ describe('MessagesPaneV2 render behavior', () => { }; const view = renderPane({ ...options, inlineThinking: true }); - expect(screen.queryByRole('region', { name: 'Live thinking content' })).toBeNull(); + const region = screen.getByRole('region', { name: 'Live thinking content' }); + expect(screen.getAllByRole('button', { name: 'Thinking...' })).toHaveLength(1); view.rerender(createPaneElement({ ...options, inlineThinking: false })); expect(screen.getByRole('button', { name: 'Thinking...' }).getAttribute('aria-expanded')).toBe('true'); - expect(screen.getByRole('region', { name: 'Live thinking content' })).toBeTruthy(); + expect(screen.getByRole('region', { name: 'Live thinking content' })).toBe(region); }); it('stops an unfinished subagent from an older run while the next run is active', () => { @@ -770,6 +767,7 @@ describe('MessagesPaneV2 render behavior', () => { timestamp: now, isAgentActivity: true, activityId: 'activity-1', + toolId: 'tool-read-1', phase: 'tool', state: 'running', title: 'Reading file', @@ -1431,7 +1429,7 @@ describe('MessagesPaneV2 render behavior', () => { expect(container.querySelector('.border-l-red-500')).toBeNull(); }); - it('shows a waiting status below an in-progress web_fetch in plan mode', () => { + it('shows a single waiting status for an in-progress web_fetch in plan mode', () => { const now = new Date().toISOString(); const messages: ChatMessage[] = [ { @@ -1460,10 +1458,11 @@ describe('MessagesPaneV2 render behavior', () => { renderPane({ messages, isAssistantWorking: true, runMode: 'plan', planModeActive: true }); - expect(screen.getByText('Fetching web content...')).toBeTruthy(); + expect(screen.getAllByText('Fetching web content...')).toHaveLength(1); + expect(document.querySelectorAll('.process-live-status')).toHaveLength(1); }); - it('does not show the web_fetch waiting status in agent mode', () => { + it('shows a single web_fetch status in agent mode', () => { const now = new Date().toISOString(); const messages: ChatMessage[] = [ { @@ -1492,7 +1491,8 @@ describe('MessagesPaneV2 render behavior', () => { renderPane({ messages, isAssistantWorking: true, runMode: 'agent' }); - expect(screen.queryByText('Fetching web content...')).toBeNull(); + expect(screen.getAllByText('Fetching web content...')).toHaveLength(1); + expect(document.querySelectorAll('.process-live-status')).toHaveLength(1); }); it('does not render a completed compact boundary as a plan-mode process row', () => { diff --git a/ui/src/components/chat-v2/MessagesPaneV2.tsx b/ui/src/components/chat-v2/MessagesPaneV2.tsx index 8338bb8fe..6e5324575 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.tsx @@ -1,7 +1,7 @@ import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; -import { XCircle, GitBranch } from 'lucide-react'; +import { XCircle, GitBranch, ArrowDown } from 'lucide-react'; import type { ChatMessage, ChatRunMode, @@ -22,16 +22,15 @@ import { useRegisterChatHistorySearchControls } from './ChatHistorySearchControl import { useChatHistorySearch } from './useChatHistorySearch'; import type { SearchableChatMessageInput } from './chatHistorySearchUtils'; import { useSubagentMessages } from './useSubagentMessages'; -import { ProcessLiveStatus, ProcessRunHeader, StreamingThinkingPreview, type ProcessTraceStep } from './ProcessTrace'; +import { ProcessLiveStatus, ProcessRunHeader, type ProcessTraceStep } from './ProcessTrace'; import { formatProcessDuration } from './processTraceUtils'; import { buildRenderableMessageItems, getLiveProcessDetailMessages, getLiveProcessGroupStep, getLiveProcessGroups, - getWebFetchWaitingStep, + isPendingToolUseMessage, shouldRenderLiveProcessGroup, - shouldShowWebFetchWaitingHint, splitLiveProcessGroupDetailMessages, type LiveProcessGroup, type RenderableMessageItem, @@ -44,8 +43,9 @@ type DiffLine = { type: string; content: string; lineNum: number }; type MessagesPaneV2Props = { scrollContainerRef: RefObject; - onWheel: () => void; - onTouchMove: () => void; + isScrollPaused?: boolean; + onResumeScroll?: () => void; + onPauseScroll?: () => void; isLoadingSessionMessages: boolean; sessionLoadError?: string | null; onRetrySessionLoad?: () => void; @@ -326,8 +326,9 @@ function isForkedChatSession(session: ProjectSession | null): boolean { function MessagesPaneV2({ scrollContainerRef, - onWheel, - onTouchMove, + isScrollPaused = false, + onResumeScroll, + onPauseScroll, isLoadingSessionMessages, sessionLoadError, onRetrySessionLoad, @@ -359,14 +360,12 @@ function MessagesPaneV2({ activeRunId = null, workingStatus, runMode = 'agent', - planModeActive = false, sessionStore, onFork, onRegenerate, forkDisabled = false, forkParentSessionTitle = null, }: MessagesPaneV2Props) { - const resolvedPlanModeActive = planModeActive || runMode === 'plan'; const { t } = useTranslation('chat'); const messageKeyMapRef = useRef>(new WeakMap()); const generatedMessageKeyCounterRef = useRef(0); @@ -510,21 +509,14 @@ function MessagesPaneV2({ ); const renderableMessages = useMemo( () => { - const lastUserIndex = isAssistantWorking - ? visibleMessages.reduce((lastIndex, message, index) => ( - message.type === 'user' ? index : lastIndex - ), -1) - : -1; - const filtered = visibleMessages.filter((message, index) => + const filtered = visibleMessages.filter((message) => !message.isAgentActivity && !isSubagentThinkingPlaceholder(message) && - !(isAssistantWorking && message.isThinking && !message.isStreaming && index < lastUserIndex) && - (!inlineThinking && isStreamingThinkingMessage(message) ? false : true) && !(message.isThinking && !showThinking) ); return filtered; }, - [visibleMessages, showThinking, inlineThinking, isAssistantWorking], + [visibleMessages, showThinking], ); const liveProcessDetailMessages = useMemo( () => isAssistantWorking ? getLiveProcessDetailMessages(renderableMessages) : [], @@ -573,11 +565,37 @@ function MessagesPaneV2({ return keyedMessageItems.map((item) => measuredHeightsRef.current.get(item.itemKey) ?? item.estimatedHeight); }, [heightVersion, keyedMessageItems]); const shouldVirtualizeMessages = keyedMessageItems.length > MESSAGE_VIRTUALIZATION_THRESHOLD; + // Keep the reader's row mounted when prepending history changes the virtual + // offsets. The shared scroll controller then corrects any measured remainder. + const virtualSnapshotRef = useRef<{ scope: string; keys: string[]; heights: number[] } | null>(null); + const previousVirtual = virtualSnapshotRef.current; + const readingKey = scrollContainerRef.current?.dataset.readingAnchorKey; + const previousReadingIndex = readingKey && previousVirtual?.scope === messageWindowScope + ? previousVirtual.keys.indexOf(readingKey) : -1; + const nextReadingIndex = readingKey ? keyedMessageItems.findIndex((item) => item.itemKey === readingKey) : -1; + const virtualAnchorDelta = shouldVirtualizeMessages && previousReadingIndex >= 0 && nextReadingIndex >= 0 && previousVirtual + ? measuredItemHeights.slice(0, nextReadingIndex).reduce((sum, height) => sum + height, 0) + - previousVirtual.heights.slice(0, previousReadingIndex).reduce((sum, height) => sum + height, 0) + : 0; + const projectedScrollTop = virtualAnchorDelta + ? (scrollContainerRef.current?.scrollTop ?? scrollViewport.scrollTop) + virtualAnchorDelta + : scrollViewport.scrollTop; + useLayoutEffect(() => { + virtualSnapshotRef.current = { + scope: messageWindowScope, + keys: keyedMessageItems.map((item) => item.itemKey), + heights: measuredItemHeights, + }; + if (virtualAnchorDelta && scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = projectedScrollTop; + setScrollViewport((current) => ({ ...current, scrollTop: scrollContainerRef.current!.scrollTop })); + } + }, [keyedMessageItems, measuredItemHeights, messageWindowScope, projectedScrollTop, scrollContainerRef, virtualAnchorDelta]); const virtualWindow = useMemo( () => shouldVirtualizeMessages ? getVirtualMessageWindow( measuredItemHeights, - scrollViewport.scrollTop, + projectedScrollTop, scrollViewport.height, MESSAGE_WINDOW_OVERSCAN, ) @@ -588,7 +606,7 @@ function MessagesPaneV2({ bottomPadding: 0, totalHeight: measuredItemHeights.reduce((sum, height) => sum + height, 0), }, - [keyedMessageItems.length, measuredItemHeights, scrollViewport.height, scrollViewport.scrollTop, shouldVirtualizeMessages], + [keyedMessageItems.length, measuredItemHeights, scrollViewport.height, projectedScrollTop, shouldVirtualizeMessages], ); const windowedMessageItems = shouldVirtualizeMessages ? keyedMessageItems.slice(virtualWindow.startIndex, virtualWindow.endIndex) @@ -661,23 +679,21 @@ function MessagesPaneV2({ item.message.content.trim().length > 0 )); }, [isAssistantWorking, keyedMessageItems, liveProcessHeaderIndex]); - const hasPendingToolUse = useMemo(() => { - if (!isAssistantWorking || liveProcessHeaderIndex < 0) return false; - const liveItems = keyedMessageItems.slice(liveProcessHeaderIndex); - let lastToolUseIdx = -1; - for (let index = liveItems.length - 1; index >= 0; index -= 1) { - if (liveItems[index]?.message.isToolUse) { - lastToolUseIdx = index; - break; - } + const currentTurnMessages = useMemo(() => { + const userIndex = visibleMessages.reduce((last, message, index) => message.type === 'user' ? index : last, -1); + return visibleMessages.slice(Math.max(0, userIndex)); + }, [visibleMessages]); + const hasPendingToolUse = currentTurnMessages.some(isPendingToolUseMessage); + const currentToolActivities = useMemo(() => nonSubagentLiveActivities.filter((activity) => { + if (activeRunId && activity.runId && activity.runId !== activeRunId) return false; + const start = currentTurnMessages[0]?.timestamp; + if (!activity.runId && start && new Date(activity.timestamp).getTime() < new Date(start).getTime()) return false; + if (activity.toolId) { + const tool = currentTurnMessages.find((message) => (message.toolId || message.toolCallId) === activity.toolId); + return Boolean(tool && isPendingToolUseMessage(tool)); } - if (lastToolUseIdx < 0) return false; - const hasContentAfterTool = liveItems.slice(lastToolUseIdx + 1).some((item) => - item.message.type === 'assistant' && !item.message.isThinking && !item.message.isToolUse && - typeof item.message.content === 'string' && item.message.content.trim().length > 0 - ); - return !hasContentAfterTool; - }, [isAssistantWorking, keyedMessageItems, liveProcessHeaderIndex]); + return activity.phase !== 'tool'; + }), [activeRunId, currentTurnMessages, nonSubagentLiveActivities]); const currentRunSubagentIds = useMemo(() => { const ids = new Set(); for (const item of keyedMessageItems) { @@ -720,7 +736,6 @@ function MessagesPaneV2({ return null; }, [isAssistantWorking, visibleMessages]); const liveThinkingContent = liveThinkingMessage?.content || null; - const streamingThinkingContent = showThinking ? liveThinkingContent : null; const liveStatusStep = useMemo(() => { if (liveThinkingContent) { return { @@ -739,23 +754,21 @@ function MessagesPaneV2({ toolName: 'agent', }; } - return getLiveStatusStep(nonSubagentLiveActivities, workingStatus, hasLiveAssistantContent, hasPendingToolUse, t); + return getLiveStatusStep(currentToolActivities, workingStatus, hasLiveAssistantContent, hasPendingToolUse, t); }, [ hasLiveAssistantContent, hasPendingToolUse, - nonSubagentLiveActivities, + currentToolActivities, runningSubagentActivity, liveThinkingContent, t, workingStatus, ]); - const hasOpenEndedLiveProcessGroup = liveProcessGroups.some((group) => group.isRunning); - const shouldRenderBottomLiveStatus = isAssistantWorking && !hasOpenEndedLiveProcessGroup; - const showStreamingThinkingPanel = Boolean(!inlineThinking && streamingThinkingContent); - const bottomLiveProcessKey = liveThinkingMessage - ? `live-thinking:${activeRunId || liveThinkingMessage.id || messageWindowScope}` - : `bottom-live:${liveStatusStep.id || 'working'}`; - const bottomLiveStatusExpanded = isProcessExpanded(bottomLiveProcessKey, showStreamingThinkingPanel); + const hasRunningProcessGroup = liveProcessGroups.some((group) => group.isRunning); + const thinkingHasOwnStatus = Boolean(showThinking && liveThinkingMessage); + const shouldRenderBottomLiveStatus = isAssistantWorking && !hasRunningProcessGroup && !thinkingHasOwnStatus; + const bottomLiveProcessKey = `bottom-live:${liveStatusStep.id || 'working'}`; + const bottomLiveStatusExpanded = isProcessExpanded(bottomLiveProcessKey); const bumpHeightVersion = useCallback(() => { if (heightVersionRafRef.current !== null) return; @@ -920,7 +933,6 @@ function MessagesPaneV2({ const renderLiveProcessGroup = useCallback((group: LiveProcessGroup, index: number) => { const isLatestGroup = liveProcessGroups[liveProcessGroups.length - 1]?.id === group.id; const step = getLiveProcessGroupStep(group, t, group.isRunning && isLatestGroup ? liveStatusStep : null); - const showWebFetchWaiting = shouldShowWebFetchWaitingHint(group, resolvedPlanModeActive); const expanded = isProcessExpanded(group.id); const { beforeStatusMessages, statusDetailMessages } = splitLiveProcessGroupDetailMessages(group); return ( @@ -940,12 +952,6 @@ function MessagesPaneV2({ ? renderLiveProcessDetailMessages(statusDetailMessages, group.id) : null} - {showWebFetchWaiting ? ( - - ) : null} ); }, [ @@ -953,7 +959,6 @@ function MessagesPaneV2({ isProcessExpanded, liveProcessGroups, liveStatusStep, - resolvedPlanModeActive, renderLiveProcessDetailMessages, t, ]); @@ -1140,6 +1145,7 @@ function MessagesPaneV2({ loadAllMessages, sessionId, renderWindowKey: `${virtualWindow.startIndex}:${virtualWindow.endIndex}`, + onNavigate: onPauseScroll, }); const searchIsRenderedByShell = useRegisterChatHistorySearchControls(chatHistorySearch); @@ -1160,8 +1166,8 @@ function MessagesPaneV2({
{hasSessionLoadError ? ( @@ -1359,19 +1365,10 @@ function MessagesPaneV2({ step={liveStatusStep} expanded={bottomLiveStatusExpanded} onExpandedChange={(expanded) => handleProcessExpandedChange(bottomLiveProcessKey, expanded)} - contentClassName={showStreamingThinkingPanel ? 'pl-0' : undefined} > - {(liveProcessDetailMessages.length > 0 && liveProcessGroups.length === 0) - || showStreamingThinkingPanel ? ( - <> - {liveProcessDetailMessages.length > 0 && liveProcessGroups.length === 0 - ? renderLiveProcessDetailMessages(liveProcessDetailMessages, 'bottom-live-process') - : null} - {showStreamingThinkingPanel && streamingThinkingContent ? ( - - ) : null} - - ) : null} + {liveProcessDetailMessages.length > 0 && liveProcessGroups.length === 0 + ? renderLiveProcessDetailMessages(liveProcessDetailMessages, 'bottom-live-process') + : null} ) : null}
@@ -1395,6 +1392,16 @@ function MessagesPaneV2({ /> ) : null} + {isScrollPaused && onResumeScroll ? ( + + ) : null} ); } @@ -1436,6 +1443,7 @@ function activityToLiveStep(activity: ChatMessage): ProcessTraceStep { severity: activity.severity, phase: activity.phase, toolName: activity.toolName, + toolId: activity.toolId, }; } diff --git a/ui/src/components/chat-v2/ProcessTrace.test.tsx b/ui/src/components/chat-v2/ProcessTrace.test.tsx index a0087bdc8..3880cfdb2 100644 --- a/ui/src/components/chat-v2/ProcessTrace.test.tsx +++ b/ui/src/components/chat-v2/ProcessTrace.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import { StreamingThinkingPreview } from './ProcessTrace'; @@ -27,12 +27,14 @@ describe('StreamingThinkingPreview', () => { expect(screen.queryByRole('button')).toBeNull(); }); - it('pauses live following while the user reads older reasoning and resumes at the bottom', () => { + it('pauses live following while the user reads older reasoning and resumes at the bottom', async () => { const view = render(); const region = screen.getByRole('region', { name: 'Live thinking content' }); Object.defineProperty(region, 'scrollHeight', { configurable: true, value: 600 }); Object.defineProperty(region, 'clientHeight', { configurable: true, value: 200 }); + await waitFor(() => expect(region.scrollTop).toBe(400)); + fireEvent.wheel(region, { deltaY: -300 }); region.scrollTop = 100; fireEvent.scroll(region); @@ -47,6 +49,8 @@ describe('StreamingThinkingPreview', () => { , ); - expect(region.scrollTop).toBe(600); + Object.defineProperty(region, 'scrollHeight', { configurable: true, value: 650 }); + view.rerender(); + await waitFor(() => expect(region.scrollTop).toBe(450)); }); }); diff --git a/ui/src/components/chat-v2/ProcessTrace.tsx b/ui/src/components/chat-v2/ProcessTrace.tsx index 9ed2cbac4..6a0aedfd9 100644 --- a/ui/src/components/chat-v2/ProcessTrace.tsx +++ b/ui/src/components/chat-v2/ProcessTrace.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState, type ReactNode, type WheelEvent } from 'react'; +import { useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Activity, @@ -14,6 +14,7 @@ import { type LucideIcon, } from 'lucide-react'; import { AgentTimeline } from './AgentTimeline'; +import { StreamingScrollViewport } from './StreamingScrollViewport'; export type ProcessTraceMetric = { key: string; @@ -28,6 +29,7 @@ export type ProcessTraceStep = { severity?: string; phase?: string; toolName?: string; + toolId?: string; }; type ProcessTraceProps = { @@ -320,55 +322,19 @@ export function StreamingThinkingPreview({ scrollable?: boolean; }) { const { t } = useTranslation('chat'); - const followLatestRef = useRef(true); - const viewportRef = useRef(null); const lines = content.split('\n'); const visibleLines = lines.slice(-maxLines); const hasOverflow = lines.length > maxLines; - useLayoutEffect(() => { - if (!scrollable || !followLatestRef.current) return; - const viewport = viewportRef.current; - if (viewport) { - viewport.scrollTop = viewport.scrollHeight; - } - }, [content, scrollable]); - - const handleScroll = () => { - const viewport = viewportRef.current; - if (!viewport) return; - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - followLatestRef.current = distanceFromBottom <= 24; - }; - - const handleWheel = (event: WheelEvent) => { - const viewport = viewportRef.current; - if (!viewport) return; - if (event.deltaY < 0) { - followLatestRef.current = false; - } - const canScrollUp = event.deltaY < 0 && viewport.scrollTop > 0; - const canScrollDown = event.deltaY > 0 - && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight; - if (canScrollUp || canScrollDown) { - event.stopPropagation(); - } - }; - if (scrollable) { return (
-
{content} -
+
); } diff --git a/ui/src/components/chat-v2/StreamingScrollViewport.tsx b/ui/src/components/chat-v2/StreamingScrollViewport.tsx new file mode 100644 index 000000000..6ec3f5505 --- /dev/null +++ b/ui/src/components/chat-v2/StreamingScrollViewport.tsx @@ -0,0 +1,25 @@ +import { useRef, type ReactNode } from 'react'; +import { useScrollFollow } from '../chat/hooks/useScrollFollow'; + +export function StreamingScrollViewport({ children, label, enabled = true, className = '' }: { + children: ReactNode; + label: string; + enabled?: boolean; + className?: string; +}) { + const containerRef = useRef(null); + useScrollFollow({ containerRef, enabled }); + return ( +
+
{children}
+
+ ); +} diff --git a/ui/src/components/chat-v2/SubagentDetailMessageFlow.render.test.tsx b/ui/src/components/chat-v2/SubagentDetailMessageFlow.render.test.tsx index af9403248..a73420db3 100644 --- a/ui/src/components/chat-v2/SubagentDetailMessageFlow.render.test.tsx +++ b/ui/src/components/chat-v2/SubagentDetailMessageFlow.render.test.tsx @@ -85,14 +85,14 @@ afterEach(() => { }); describe('SubagentDetailMessageFlow', () => { - it('renders streaming subagent thinking through the live preview channel', () => { + it('renders streaming subagent thinking once before the tool status', async () => { renderFlow([ assistant('a-1', 'I will edit the file.', 100), streamingThinking('Choose the smallest patch.', 200), tool('edit-1', 'Edit', 300), ]); - const thinkingText = screen.getByText('Choose the smallest patch.'); + const thinkingText = await screen.findByText('Choose the smallest patch.'); const status = screen.getByRole('status'); expect(screen.getAllByText('Choose the smallest patch.')).toHaveLength(1); @@ -110,7 +110,7 @@ describe('SubagentDetailMessageFlow', () => { expect(screen.getAllByText('Standalone thought two.')).toHaveLength(1); }); - it('keeps completed subagent thinking inside related tool status details', () => { + it('keeps completed subagent thinking in its own rows outside tool status details', () => { renderFlow([ assistant('a-1', 'I will inspect the issue.', 100), thinking('think-1', 'Completed thought one.', 200), @@ -118,7 +118,7 @@ describe('SubagentDetailMessageFlow', () => { tool('read-1', 'Read', 400), ], false); - expect(screen.queryByText('Completed thought one.')).toBeNull(); + expect(screen.getAllByRole('button', { name: /Thought process|思考过程/i })).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: /Explored 1 file|已探索 1 个文件/i })); const status = screen.getByRole('status'); @@ -126,8 +126,8 @@ describe('SubagentDetailMessageFlow', () => { expect(screen.getAllByText('Completed thought one.')).toHaveLength(1); expect(screen.getAllByText('Completed thought two.')).toHaveLength(1); - expect(processRow?.textContent).toContain('Completed thought one.'); - expect(processRow?.textContent).toContain('Completed thought two.'); + expect(processRow?.textContent).not.toContain('Completed thought one.'); + expect(processRow?.textContent).not.toContain('Completed thought two.'); expect(status.textContent).not.toContain('Completed thought one.'); expect(status.textContent).not.toContain('Completed thought two.'); expect(screen.queryByText('Thought through next step')).toBeNull(); diff --git a/ui/src/components/chat-v2/SubagentDetailMessageFlow.tsx b/ui/src/components/chat-v2/SubagentDetailMessageFlow.tsx index e08ff574f..68a67400e 100644 --- a/ui/src/components/chat-v2/SubagentDetailMessageFlow.tsx +++ b/ui/src/components/chat-v2/SubagentDetailMessageFlow.tsx @@ -1,15 +1,16 @@ import { Fragment, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ArrowDown } from 'lucide-react'; import type { ChatMessage, ChatRunMode } from '../chat/types/types'; +import { useScrollFollow } from '../chat/hooks/useScrollFollow'; import type { Project, SessionProvider } from '../../types/app'; import ChatHistorySearchBar from './ChatHistorySearchBar'; import MessageRowV2 from './MessageRowV2'; -import { ProcessLiveStatus, StreamingThinkingPreview, type ProcessTraceStep } from './ProcessTrace'; +import { ProcessLiveStatus, type ProcessTraceStep } from './ProcessTrace'; import { buildRenderableMessageItems, getLiveProcessGroups, getLiveProcessGroupStep, - getProcessToolKind, shouldRenderLiveProcessGroup, splitLiveProcessGroupDetailMessages, type LiveProcessGroup, @@ -39,7 +40,7 @@ type KeyedRenderableMessageItem = RenderableMessageItem & { function getMessageKey(message: ChatMessage, index: number): string { return String( - message.id || + message.renderKey || message.id || message.toolId || message.activityId || message.runId || @@ -89,6 +90,7 @@ export default function SubagentDetailMessageFlow({ }: SubagentDetailMessageFlowProps) { const { t } = useTranslation('chat'); const scrollContainerRef = useRef(null); + const follow = useScrollFollow({ containerRef: scrollContainerRef, enabled: isRunning, contentKey: messages.length > 0, contentSelector: '[data-chat-scroll-content]' }); const [expandedProcessRows, setExpandedProcessRows] = useState>(() => new Map()); const [expandedToolSections, setExpandedToolSections] = useState>(() => new Map()); @@ -108,40 +110,19 @@ export default function SubagentDetailMessageFlow({ }, [isRunning, messages, showThinking]); const thinkingStatusStep = useMemo(() => { - const lastToolMsg = [...messages].reverse().find( - (m) => m.isToolUse && m.toolName && !m.isSubagentContainer, - ); - if (lastToolMsg) { - const kind = getProcessToolKind(lastToolMsg); - const toolKindTitleMap: Record = { - search: t('process.live.runningSearch', { defaultValue: 'Searching' }), - edit: t('process.live.runningEdit', { defaultValue: 'Editing file' }), - read: t('process.live.runningRead', { defaultValue: 'Reading file' }), - command: t('process.live.runningCommand', { defaultValue: 'Running command' }), - }; - if (toolKindTitleMap[kind]) { - return { - id: 'subagent-detail-thinking', - title: toolKindTitleMap[kind], - phase: kind === 'search' ? 'rag' : 'tool', - state: 'running' as const, - }; - } - } return { id: 'subagent-detail-thinking', title: t('subagent.status.thinking', { defaultValue: 'Thinking' }), phase: 'thinking', state: 'running' as const, }; - }, [messages, t]); + }, [t]); const renderableMessages = useMemo( () => { const result = messages .filter((message) => !message.isAgentActivity && - !isStreamingSubagentThinkingMessage(message) && !(message.isThinking && !showThinking) ) .map((message) => message.isSubagentContainer @@ -212,8 +193,7 @@ export default function SubagentDetailMessageFlow({ [keyedItems, unanchoredLiveProcessGroups], ); const hasOpenEndedLiveProcessGroup = liveProcessGroups.some((group) => group.isRunning); - const shouldRenderBottomLiveStatus = isRunning && !hasOpenEndedLiveProcessGroup; - const shouldRenderBottomStreamingThinking = Boolean(streamingThinkingContent && !hasOpenEndedLiveProcessGroup); + const shouldRenderBottomLiveStatus = isRunning && !hasOpenEndedLiveProcessGroup && !streamingThinkingContent; const keyedMessagesForSearch = useMemo(() => { return keyedItems.map((item) => ( { @@ -263,6 +243,7 @@ export default function SubagentDetailMessageFlow({ loadAllMessages: loadAllSearchMessages, sessionId: null, captureFindShortcutInModal: true, + onNavigate: follow.pause, }); const renderLiveProcessDetailMessages = useCallback((detailMessages: ChatMessage[], groupId: string) => { @@ -296,11 +277,9 @@ export default function SubagentDetailMessageFlow({ ]); const renderLiveProcessGroup = useCallback((group: LiveProcessGroup, index: number) => { - const isLatestGroup = liveProcessGroups[liveProcessGroups.length - 1]?.id === group.id; - const step = getLiveProcessGroupStep(group, t, group.isRunning && isLatestGroup ? thinkingStatusStep : null); + const step = getLiveProcessGroupStep(group, t, null); const expanded = isProcessExpanded(group.id); const { beforeStatusMessages, statusDetailMessages } = splitLiveProcessGroupDetailMessages(group); - const showStreamingThinkingBeforeStatus = Boolean(streamingThinkingContent && group.isRunning && isLatestGroup); return ( {expanded && beforeStatusMessages.length > 0 ? ( @@ -308,11 +287,6 @@ export default function SubagentDetailMessageFlow({ {renderLiveProcessDetailMessages(beforeStatusMessages, `${group.id}-before-status`)} ) : null} - {showStreamingThinkingBeforeStatus ? ( -
- -
- ) : null} ) : null} -
-
+
+
{keyedItems.map((item) => { const previousMessage = item.renderIndex > 0 ? keyedItems[item.renderIndex - 1].message : null; const nextMessage = item.renderIndex < keyedItems.length - 1 @@ -411,16 +381,15 @@ export default function SubagentDetailMessageFlow({ {bottomUnanchoredLiveProcessGroups.map(renderLiveProcessGroup)}
) : null} - {shouldRenderBottomLiveStatus || shouldRenderBottomStreamingThinking ? ( -
- - {shouldRenderBottomStreamingThinking ? ( - - ) : null} -
- ) : null} + {shouldRenderBottomLiveStatus ? : null}
+ {follow.isPaused ? ( + + ) : null}
); } diff --git a/ui/src/components/chat-v2/ThinkingBlock.tsx b/ui/src/components/chat-v2/ThinkingBlock.tsx new file mode 100644 index 000000000..ffafa879f --- /dev/null +++ b/ui/src/components/chat-v2/ThinkingBlock.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { ChevronRight, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Markdown } from '../chat/view/subcomponents/Markdown'; +import { StreamingScrollViewport } from './StreamingScrollViewport'; +import { useTypewriter } from './useTypewriter'; + +export function ThinkingBlock({ content, isStreaming, inline, projectName, onFileOpen }: { + content: string; + isStreaming: boolean; + inline?: boolean; + projectName?: string; + onFileOpen?: (path: string) => void; +}) { + // Completion updates this same block; it must not close underneath its reader. + const [expanded, setExpanded] = useState(isStreaming); + const { t } = useTranslation('chat'); + const text = useTypewriter(content, isStreaming, 4); + return ( +
+ + {/* Keep the viewport mounted when collapsed so reopening retains its position. */} + +
+ ); +} diff --git a/ui/src/components/chat-v2/processGrouping.streaming.test.ts b/ui/src/components/chat-v2/processGrouping.streaming.test.ts new file mode 100644 index 000000000..311c96442 --- /dev/null +++ b/ui/src/components/chat-v2/processGrouping.streaming.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import type { ChatMessage } from '../chat/types/types'; +import { buildRenderableMessageItems, getLiveProcessGroups, isPendingToolUseMessage } from './processGrouping'; + +const user: ChatMessage = { id: 'user', type: 'user', content: 'Start', timestamp: '2026-09-05' }; +const tool = (id: string, finished = false): ChatMessage => ({ + id, type: 'assistant', content: '', isToolUse: true, toolId: id, toolName: 'web_fetch', timestamp: '2026-09-05', + ...(finished ? { toolResult: { content: '', isError: false } } : {}), +}); + +describe('streaming process ownership', () => { + it('accepts an empty successful result as completion', () => { + expect(isPendingToolUseMessage(tool('done', true))).toBe(false); + expect(getLiveProcessGroups([user, tool('done', true)], { isAssistantWorking: true })[0].isRunning).toBe(false); + }); + + it('keeps an earlier pending group running when a later group has completed', () => { + const explanation: ChatMessage = { id: 'text', type: 'assistant', content: 'Other work', timestamp: '2026-09-05' }; + const groups = getLiveProcessGroups([user, tool('pending'), explanation, tool('done', true)], { isAssistantWorking: true }); + expect(groups.map((group) => group.isRunning)).toEqual([true, false]); + }); + + it('waits for every parallel invocation, including the earlier invocation', () => { + expect(getLiveProcessGroups([user, tool('pending'), tool('done', true)], { isAssistantWorking: true })[0].isRunning).toBe(true); + expect(getLiveProcessGroups([user, tool('pending', true), tool('done', true)], { isAssistantWorking: true })[0].isRunning).toBe(false); + }); + + it('keeps reasoning in the same row when a tool arrives and the run completes', () => { + const thinking: ChatMessage = { id: 'thought', type: 'assistant', isThinking: true, isStreaming: true, content: 'Reasoning', timestamp: '2026-09-05' }; + const live = buildRenderableMessageItems([user, thinking], { isAssistantWorking: true }); + const afterTool = buildRenderableMessageItems([user, { ...thinking, isStreaming: false }, tool('done', true)], { isAssistantWorking: false }); + expect(live.some((item) => item.message.id === thinking.id)).toBe(true); + expect(afterTool.some((item) => item.message.id === thinking.id)).toBe(true); + }); +}); diff --git a/ui/src/components/chat-v2/processGrouping.test.ts b/ui/src/components/chat-v2/processGrouping.test.ts index 256afb860..0dea6b106 100644 --- a/ui/src/components/chat-v2/processGrouping.test.ts +++ b/ui/src/components/chat-v2/processGrouping.test.ts @@ -497,7 +497,7 @@ describe('processGrouping', () => { expect(groups[0].messages.map((message) => message.id)).toEqual(['read-1', 'grep-1']); }); - it('does not render thinking as standalone after empty live assistant shells', () => { + it('keeps thinking standalone after empty live assistant shells to preserve its viewport', () => { const messages = [ user('u1'), assistant('a1', 'Starting work.', 100), @@ -509,10 +509,10 @@ describe('processGrouping', () => { const items = buildRenderableMessageItems(messages, { isAssistantWorking: true }); const groups = getLiveProcessGroups(messages, { isAssistantWorking: true }); - expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']); + expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1', 'think-1']); expect(groups).toHaveLength(1); - expect(groups[0].messages.map((message) => message.id)).toEqual(['bash-1', 'think-1']); - expect(groups[0].detailMessages.map((message) => message.id)).toEqual(['bash-1', 'think-1']); + expect(groups[0].messages.map((message) => message.id)).toEqual(['bash-1']); + expect(groups[0].detailMessages.map((message) => message.id)).toEqual(['bash-1']); }); it('keeps leading live thinking inside the current running status details', () => { diff --git a/ui/src/components/chat-v2/processGrouping.ts b/ui/src/components/chat-v2/processGrouping.ts index 32b8bb0d9..6ac7c1e5a 100644 --- a/ui/src/components/chat-v2/processGrouping.ts +++ b/ui/src/components/chat-v2/processGrouping.ts @@ -336,7 +336,7 @@ function isUserVisibleTool(message: ChatMessage): boolean { } export function isProcessMessage(message: ChatMessage): boolean { - if (message.isAgentActivity || message.isAgentActivitySummary) { + if (message.isThinking || message.isAgentActivity || message.isAgentActivitySummary) { return false; } if (message.type === 'user' || message.type === 'error') { @@ -352,7 +352,6 @@ export function isProcessMessage(message: ChatMessage): boolean { message.isToolUse || message.isTaskNotification || message.isCompactBoundary || - (message.isThinking && !message.isStreaming) || message.type === 'tool', ); } @@ -979,12 +978,10 @@ export function getLiveProcessGroups( finishGroup(null); - const result = groups.map((group, index) => { - const isLatestGroup = index === groups.length - 1; - const isOpenEnded = group.beforeOriginalIndex == null; + const result = groups.map((group) => { return { ...group, - isRunning: Boolean(options.isAssistantWorking && isLatestGroup && isOpenEnded), + isRunning: Boolean(options.isAssistantWorking && group.messages.some(isPendingToolUseMessage)), }; }); return result; @@ -1014,13 +1011,8 @@ export function isPendingToolUseMessage(message: ChatMessage): boolean { if (!message.isToolUse && message.type !== 'tool') { return false; } - if (!message.toolResult) { - return true; - } - const content = typeof message.toolResult.content === 'string' - ? message.toolResult.content.trim() - : ''; - return content.length === 0 && !message.toolResult.isError; + // A successful empty result still represents a finished invocation. + return message.toolResult == null; } export function shouldShowWebFetchWaitingHint( @@ -1144,13 +1136,16 @@ export function getRunningProcessTitle( group: LiveProcessGroup, t: TFunction<'chat'>, ): string { - const latestMessage = [...group.messages].reverse().find((message) => isProcessMessage(message)); + const latestMessage = [...group.messages].reverse().find(isPendingToolUseMessage); if (!latestMessage) { return t('working.processing', { defaultValue: 'Processing' }); } const kind = getProcessToolKind(latestMessage); const target = getDisplayTarget(getToolTarget(latestMessage)); + if (isWebFetchToolMessage(latestMessage)) { + return t('working.waitingForWebFetch', { defaultValue: 'Fetching web content...' }); + } if (kind === 'edit') { return target ? t('process.live.runningEditTarget', { target, defaultValue: `Editing ${target}` }) @@ -1190,7 +1185,9 @@ export function getLiveProcessGroupStep( ): ProcessTraceStep { const fallbackPhase = String(fallbackRunningStep?.phase || ''); const canUseFallbackStep = fallbackRunningStep?.title && - !['generation', 'thinking', 'permission'].includes(fallbackPhase); + !['generation', 'thinking', 'permission'].includes(fallbackPhase) && + Boolean(fallbackRunningStep?.toolId && group.messages.some((message) => + (message.toolId || message.toolCallId) === fallbackRunningStep.toolId && isPendingToolUseMessage(message))); if (group.isRunning && canUseFallbackStep) { return { ...fallbackRunningStep, @@ -1202,7 +1199,9 @@ export function getLiveProcessGroupStep( const title = group.isRunning ? getRunningProcessTitle(group, t) : formatCompletedProcessTitle(group.messages, t); - const latestMessage = group.messages[group.messages.length - 1]; + const latestMessage = group.isRunning + ? [...group.messages].reverse().find(isPendingToolUseMessage) + : group.messages[group.messages.length - 1]; const kind = latestMessage ? getProcessToolKind(latestMessage) : 'tool'; return { diff --git a/ui/src/components/chat-v2/useChatHistorySearch.ts b/ui/src/components/chat-v2/useChatHistorySearch.ts index 3f01e51dc..a9a46e781 100644 --- a/ui/src/components/chat-v2/useChatHistorySearch.ts +++ b/ui/src/components/chat-v2/useChatHistorySearch.ts @@ -18,10 +18,11 @@ type UseChatHistorySearchOptions = { measuredItemHeights: number[]; allMessagesLoaded: boolean; hasMoreMessages: boolean; - loadAllMessages: () => void; + loadAllMessages: () => void | Promise; sessionId: string | null; captureFindShortcutInModal?: boolean; renderWindowKey?: string | number; + onNavigate?: () => void; }; export function useChatHistorySearch({ @@ -34,11 +35,14 @@ export function useChatHistorySearch({ sessionId, captureFindShortcutInModal = false, renderWindowKey = 0, + onNavigate, }: UseChatHistorySearchOptions) { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(''); const [activeMatchIndex, setActiveMatchIndex] = useState(0); const inputRef = useRef(null); + const navigationRef = useRef(0); + const lastRevealedRef = useRef(null); const searchableMessages = useMemo( () => buildSearchableMessages(keyedMessages), @@ -53,6 +57,8 @@ export function useChatHistorySearch({ const activeMatch: ChatHistorySearchMatch | null = matches[activeMatchIndex] ?? null; const closeSearch = useCallback(() => { + navigationRef.current += 1; + lastRevealedRef.current = null; setIsOpen(false); setQuery(''); setActiveMatchIndex(0); @@ -70,8 +76,7 @@ export function useChatHistorySearch({ const ensureAllMessagesLoaded = useCallback(async () => { if (!hasMoreMessages || allMessagesLoaded) return; - loadAllMessages(); - await new Promise((resolve) => setTimeout(resolve, 350)); + await loadAllMessages(); }, [allMessagesLoaded, hasMoreMessages, loadAllMessages]); const applySearchHighlights = useCallback((match: ChatHistorySearchMatch | null) => { @@ -86,16 +91,23 @@ export function useChatHistorySearch({ ); }, [matches, query, scrollContainerRef, searchableMessages]); + const navigationDataRef = useRef({ applySearchHighlights, measuredItemHeights, matches }); + navigationDataRef.current = { applySearchHighlights, measuredItemHeights, matches }; + const revealMatch = useCallback(async (match: ChatHistorySearchMatch) => { + const navigation = ++navigationRef.current; + onNavigate?.(); await ensureAllMessagesLoaded(); + if (navigation !== navigationRef.current) return; const container = scrollContainerRef.current; if (!container) return; const revealRenderedMatch = (behavior: ScrollBehavior): boolean => { - const target = applySearchHighlights(match); + const target = navigationDataRef.current.applySearchHighlights(match); if (!target) return false; scrollSearchTargetIntoView(container, target, behavior); + onNavigate?.(); return true; }; @@ -107,7 +119,9 @@ export function useChatHistorySearch({ // A distant result may not exist in the DOM yet. Perform one instant // coarse jump so virtualization can mount it, then center it without a // second long animation. - scrollToMessageIndex(container, measuredItemHeights, match.messageIndex); + const currentMatch = navigationDataRef.current.matches.find((candidate) => + candidate.messageKey === match.messageKey && candidate.offset === match.offset) ?? match; + scrollToMessageIndex(container, navigationDataRef.current.measuredItemHeights, currentMatch.messageIndex); await new Promise((resolve) => { requestAnimationFrame(() => { @@ -115,11 +129,10 @@ export function useChatHistorySearch({ }); }); - revealRenderedMatch('auto'); + if (navigation === navigationRef.current) revealRenderedMatch('auto'); }, [ - applySearchHighlights, ensureAllMessagesLoaded, - measuredItemHeights, + onNavigate, scrollContainerRef, ]); @@ -165,10 +178,14 @@ export function useChatHistorySearch({ const container = scrollContainerRef.current; if (!isOpen || !activeMatch || !query.trim()) { if (container) clearSearchHighlights(container); + lastRevealedRef.current = null; return; } + const key = `${sessionId}:${query}:${activeMatch.messageKey}:${activeMatch.offset}`; + if (lastRevealedRef.current === key) return; + lastRevealedRef.current = key; void revealMatch(activeMatch); - }, [activeMatch, isOpen, query, revealMatch, scrollContainerRef]); + }, [activeMatch, isOpen, query, revealMatch, scrollContainerRef, sessionId]); useEffect(() => { if (!isOpen || !query.trim()) return undefined; @@ -192,7 +209,15 @@ export function useChatHistorySearch({ if (!isOpen) return; const container = scrollContainerRef.current; if (!container) return; - return () => clearSearchHighlights(container); + const cancelNavigation = () => { navigationRef.current += 1; }; + container.addEventListener('wheel', cancelNavigation, { passive: true }); + container.addEventListener('touchmove', cancelNavigation, { passive: true }); + return () => { + navigationRef.current += 1; + container.removeEventListener('wheel', cancelNavigation); + container.removeEventListener('touchmove', cancelNavigation); + clearSearchHighlights(container); + }; }, [isOpen, scrollContainerRef]); return { diff --git a/ui/src/components/chat-v2/useTypewriter.test.tsx b/ui/src/components/chat-v2/useTypewriter.test.tsx new file mode 100644 index 000000000..2106a700e --- /dev/null +++ b/ui/src/components/chat-v2/useTypewriter.test.tsx @@ -0,0 +1,60 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTypewriter } from './useTypewriter'; + +let frames: Map; +let next: number; +let time: number; +function advance(milliseconds: number, hz = 60) { + const end = time + milliseconds; + while (time < end - 0.01) { + time = Math.min(end, time + 1000 / hz); + act(() => { + const pending = [...frames.values()]; frames.clear(); + pending.forEach((callback) => callback(time)); + }); + } +} +beforeEach(() => { + frames = new Map(); next = 0; time = 0; + vi.spyOn(performance, 'now').mockImplementation(() => time); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++next, callback); return next; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('stream text presentation', () => { + it('resumes after Strict Mode replays the effects', () => { + const view = renderHook(() => useTypewriter('Live response', true), { wrapper: StrictMode }); + advance(1000); + expect(view.result.current).toBe('Live response'); + }); + it('uses elapsed time instead of display refresh rate', () => { + const a = renderHook(() => useTypewriter('x'.repeat(40), true, 4)); + advance(100, 60); const at60 = a.result.current.length; a.unmount(); + const b = renderHook(() => useTypewriter('x'.repeat(40), true, 4)); + advance(100, 120); + expect(Math.abs(b.result.current.length - at60)).toBeLessThanOrEqual(1); + }); + it('drains outstanding text on completion without dumping the remaining block', () => { + const fullText = 'x'.repeat(1000); + const view = renderHook(({ streaming }) => useTypewriter(fullText, streaming), { initialProps: { streaming: true } }); + advance(30); const before = view.result.current; + view.rerender({ streaming: false }); + expect(view.result.current).toBe(before); + advance(50); + expect(view.result.current.length).toBeGreaterThan(before.length); + expect(view.result.current.length).toBeLessThan(fullText.length); + advance(1000); + expect(view.result.current).toBe(fullText); + expect(frames.size).toBe(0); + }); + it('shows history immediately and cancels pending animation when unmounted', () => { + const history = renderHook(() => useTypewriter('Saved response', false)); + expect(history.result.current).toBe('Saved response'); + const live = renderHook(() => useTypewriter('Live response', true)); + live.unmount(); + expect(frames.size).toBe(0); + }); +}); diff --git a/ui/src/components/chat-v2/useTypewriter.ts b/ui/src/components/chat-v2/useTypewriter.ts index dd3640cbb..e8c7816b3 100644 --- a/ui/src/components/chat-v2/useTypewriter.ts +++ b/ui/src/components/chat-v2/useTypewriter.ts @@ -1,70 +1,60 @@ -import { useRef, useState, useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -/** - * Progressively reveals text with adaptive speed. - * - When lag is large (burst arrival), renders fast to catch up - * - When nearly caught up, renders at a smooth readable pace - * - When not streaming (e.g. after refresh), shows full text immediately - */ +/** Reveal text at the same pace on 60/120 Hz displays, draining the final tail. */ export function useTypewriter(fullText: string, isStreaming: boolean, baseCharsPerFrame = 3): string { - const [displayLen, setDisplayLen] = useState(() => - isStreaming ? 0 : fullText.length, - ); - const rafRef = useRef(null); - const targetLenRef = useRef(fullText.length); - const baseCharsRef = useRef(baseCharsPerFrame); - - targetLenRef.current = fullText.length; - baseCharsRef.current = baseCharsPerFrame; - - const pump = useCallback(() => { - rafRef.current = null; - setDisplayLen((prev) => { - const target = targetLenRef.current; - if (prev >= target) return prev; - - // Adaptive speed: faster when far behind, slower when nearly caught up - const lag = target - prev; - let chars: number; - if (lag > 200) { - chars = Math.ceil(lag * 0.15); // Catch up fast: ~15% of lag per frame - } else if (lag > 50) { - chars = Math.ceil(lag * 0.1); // Medium speed - } else { - chars = baseCharsRef.current; // Normal speed when nearly caught up - } - - const next = Math.min(prev + chars, target); - rafRef.current = requestAnimationFrame(pump); - return next; - }); + const [displayLen, setDisplayLen] = useState(isStreaming ? 0 : fullText.length); + const lengthRef = useRef(displayLen); + const hasStreamedRef = useRef(isStreaming); + const targetRef = useRef({ fullText, isStreaming, baseCharsPerFrame }); + targetRef.current = { fullText, isStreaming, baseCharsPerFrame }; + const frameRef = useRef(null); + const lastTimeRef = useRef(null); + const budgetRef = useRef(0); + + const pump = useCallback((time: number) => { + frameRef.current = null; + const target = targetRef.current; + const lag = target.fullText.length - lengthRef.current; + if (lag <= 0) { + lastTimeRef.current = null; + return; + } + const elapsed = Math.min(64, Math.max(0, time - (lastTimeRef.current ?? time))); + lastTimeRef.current = time; + const rate = Math.max(target.baseCharsPerFrame * 60, lag / (target.isStreaming ? 0.2 : 0.08)); + budgetRef.current += rate * elapsed / 1000; + const count = Math.floor(budgetRef.current); + budgetRef.current -= count; + if (count > 0) { + lengthRef.current = Math.min(target.fullText.length, lengthRef.current + count); + setDisplayLen(lengthRef.current); + } + if (lengthRef.current < target.fullText.length) frameRef.current = requestAnimationFrame(pump); + else lastTimeRef.current = null; }, []); - // Kick-start pump whenever new text arrives and pump is idle useEffect(() => { - if (!isStreaming) { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } + if (isStreaming) hasStreamedRef.current = true; + if (!hasStreamedRef.current) { + lengthRef.current = fullText.length; setDisplayLen(fullText.length); return; } - if (rafRef.current === null && fullText.length > 0) { - rafRef.current = requestAnimationFrame(pump); + if (lengthRef.current > fullText.length) { + lengthRef.current = fullText.length; + setDisplayLen(fullText.length); } - }, [isStreaming, fullText.length, pump]); + if (frameRef.current === null && lengthRef.current < fullText.length) { + lastTimeRef.current = performance.now(); + frameRef.current = requestAnimationFrame(pump); + } + }, [fullText, isStreaming, pump]); - // Cleanup on unmount - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - }; + useEffect(() => () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + lastTimeRef.current = null; }, []); - if (!isStreaming) return fullText; - return fullText.slice(0, displayLen); + return !hasStreamedRef.current && !isStreaming ? fullText : fullText.slice(0, displayLen); } diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 3e0ced30c..73b48166c 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -1518,7 +1518,7 @@ export function useChatComposerState({ }); setIsUserScrolledUp(false); - setTimeout(() => scrollToBottom(), 100); + scrollToBottom(); if (!effectiveSessionId && !submitSelectedSession?.id) { if (typeof window !== 'undefined') { diff --git a/ui/src/components/chat/hooks/useChatMessages.ts b/ui/src/components/chat/hooks/useChatMessages.ts index 6f6a35c08..dcc286317 100644 --- a/ui/src/components/chat/hooks/useChatMessages.ts +++ b/ui/src/components/chat/hooks/useChatMessages.ts @@ -80,6 +80,7 @@ function convertSingleMessage( options: ConvertSingleMessageOptions = {}, ): ChatMessage | null { const turnIdentity = { + ...(msg.renderKey ? { renderKey: msg.renderKey } : {}), ...(msg.runId ? { runId: msg.runId } : {}), ...(msg.turnId || msg.runId ? { turnId: msg.turnId || msg.runId } : {}), }; @@ -230,7 +231,7 @@ function convertSingleMessage( timestamp: msg.timestamp, ...turnIdentity, isThinking: true, - isStreaming: msg.id.startsWith('__streaming_thinking_'), + isStreaming: msg.id.startsWith('__streaming_thinking_') || msg.id.startsWith('__subagent_thinking_'), }; } return null; diff --git a/ui/src/components/chat/hooks/useChatSessionState.spec.ts b/ui/src/components/chat/hooks/useChatSessionState.spec.ts index 9327f7c44..8b1e4f97c 100644 --- a/ui/src/components/chat/hooks/useChatSessionState.spec.ts +++ b/ui/src/components/chat/hooks/useChatSessionState.spec.ts @@ -27,6 +27,13 @@ describe('chatMessageToNormalized', () => { }); describe('resolveConversationScrollTop', () => { + it('preserves an explicitly paused position even a few pixels from the bottom', () => { + expect(resolveConversationScrollTop( + { top: 795, distanceFromBottom: 5, following: false }, + 1600, + 400, + )).toBe(795); + }); it('keeps a conversation pinned to the bottom when it was near the bottom', () => { expect(resolveConversationScrollTop( { top: 720, distanceFromBottom: 20 }, diff --git a/ui/src/components/chat/hooks/useChatSessionState.ts b/ui/src/components/chat/hooks/useChatSessionState.ts index 5e2094970..32c55af12 100644 --- a/ui/src/components/chat/hooks/useChatSessionState.ts +++ b/ui/src/components/chat/hooks/useChatSessionState.ts @@ -22,6 +22,7 @@ import { invalidateSessionStatusResponses, } from '../sessionStatusProtocol'; import { normalizedToChatMessages } from './useChatMessages'; +import { useScrollFollow } from './useScrollFollow'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -53,11 +54,6 @@ interface UseChatSessionStateArgs { sessionStore: SessionStore; } -interface ScrollRestoreState { - height: number; - top: number; -} - export function isScrollNearBottom( scrollTop: number, scrollHeight: number, @@ -271,6 +267,7 @@ function hasEquivalentUserMessage(messages: ChatMessage[], pendingUserMessage: C type ConversationScrollPosition = { top: number; distanceFromBottom: number; + following?: boolean; }; const CONVERSATION_SCROLL_BOTTOM_THRESHOLD = 40; @@ -281,7 +278,7 @@ export function resolveConversationScrollTop( clientHeight: number, ): number { const maximumScrollTop = Math.max(0, scrollHeight - clientHeight); - if (position.distanceFromBottom <= CONVERSATION_SCROLL_BOTTOM_THRESHOLD) { + if (position.following ?? position.distanceFromBottom <= CONVERSATION_SCROLL_BOTTOM_THRESHOLD) { return maximumScrollTop; } return Math.min(Math.max(0, position.top), maximumScrollTop); @@ -321,7 +318,6 @@ export function useChatSessionState({ const [totalMessages, setTotalMessages] = useState(0); const [canAbortSession, setCanAbortSession] = useState(false); const [isAborting, setIsAborting] = useState(false); - const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [tokenBudget, setTokenBudget] = useState | null>(null); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [claudeStatus, setClaudeStatus] = useState(null); @@ -339,10 +335,8 @@ export function useChatSessionState({ const isLoadingMoreRef = useRef(false); const allMessagesLoadedRef = useRef(false); const topLoadLockRef = useRef(false); - const pendingScrollRestoreRef = useRef(null); const pendingInitialScrollRef = useRef(true); const messagesOffsetRef = useRef(0); - const scrollPositionRef = useRef({ height: 0, top: 0 }); const conversationScrollPositionsRef = useRef(new Map()); const pendingConversationScrollRestoreRef = useRef<{ key: string; @@ -351,17 +345,9 @@ export function useChatSessionState({ const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); const lastLoadedSessionKeyRef = useRef(null); - const followScrollFrameRef = useRef(null); const createDiff = useMemo(() => createCachedDiffCalculator(), []); - useEffect(() => () => { - if (followScrollFrameRef.current !== null) { - cancelAnimationFrame(followScrollFrameRef.current); - followScrollFrameRef.current = null; - } - }, []); - /* ---------------------------------------------------------------- */ /* Derive chatMessages from the store */ /* ---------------------------------------------------------------- */ @@ -534,46 +520,24 @@ export function useChatSessionState({ const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []); - const scrollToBottom = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - container.scrollTop = container.scrollHeight; - }, []); - - const scheduleScrollToBottom = useCallback(() => { - if (followScrollFrameRef.current !== null) { - return; - } - followScrollFrameRef.current = requestAnimationFrame(() => { - followScrollFrameRef.current = null; - scrollToBottom(); - }); - }, [scrollToBottom]); - - useEffect(() => { - if ( - !shouldFollowConversationScroll(autoScrollToBottom, isUserScrolledUp) - || typeof ResizeObserver === 'undefined' - ) return undefined; - const container = scrollContainerRef.current; - const content = container?.querySelector('[data-chat-scroll-content]'); - if (!container || !content) return undefined; - - const observer = new ResizeObserver(() => { - scheduleScrollToBottom(); - }); - observer.observe(content); - scheduleScrollToBottom(); - - return () => observer.disconnect(); - }, [ - activeScrollKey, - autoScrollToBottom, - chatMessages.length, - isLoadingSessionMessages, - isUserScrolledUp, - scheduleScrollToBottom, - ]); + const { + isPaused: isUserScrolledUp, + setPaused: setIsUserScrolledUp, + getIsPaused, + pause: pauseScrollFollowing, + scrollToBottom, + scheduleFollow: scheduleScrollToBottom, + captureAnchor: captureReadingAnchor, + } = useScrollFollow({ + containerRef: scrollContainerRef, + enabled: Boolean(autoScrollToBottom), + scopeKey: activeScrollKey, + contentKey: isLoadingSessionMessages || chatMessages.length === 0, + contentSelector: '[data-chat-scroll-content]', + canFollow: () => !searchScrollActiveRef.current && !isLoadingMoreRef.current && !isLoadingSessionMessages, + }); + const activeScrollKeyRef = useRef(activeScrollKey); + activeScrollKeyRef.current = activeScrollKey; const scrollToBottomAndReset = useCallback(() => { scrollToBottom(); @@ -598,8 +562,8 @@ export function useChatSessionState({ if (!hasMoreMessages || !selectedSession || !selectedProject) return false; isLoadingMoreRef.current = true; - const previousScrollHeight = container.scrollHeight; - const previousScrollTop = container.scrollTop; + const requestScrollKey = activeScrollKey; + captureReadingAnchor(); try { const slot = await sessionStore.fetchMore(selectedSession.id, { @@ -609,9 +573,7 @@ export function useChatSessionState({ ...sessionRequestParams, limit: MESSAGES_PER_PAGE, }); - if (!slot || slot.serverMessages.length === 0) return false; - - pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; + if (!slot || slot.serverMessages.length === 0 || activeScrollKeyRef.current !== requestScrollKey) return false; setHasMoreMessages(slot.hasMore); setTotalMessages(slot.total); setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE); @@ -621,6 +583,8 @@ export function useChatSessionState({ } }, [ + activeScrollKey, + captureReadingAnchor, hasMoreMessages, isLoadingMoreMessages, selectedProject, @@ -637,6 +601,7 @@ export function useChatSessionState({ if (activeScrollKey) { conversationScrollPositionsRef.current.set(activeScrollKey, { top: container.scrollTop, + following: !getIsPaused(), distanceFromBottom: Math.max( 0, container.scrollHeight - container.scrollTop - container.clientHeight, @@ -644,9 +609,6 @@ export function useChatSessionState({ }); } - const nearBottom = isNearBottom(); - setIsUserScrolledUp(!nearBottom); - if (!allMessagesLoadedRef.current) { const scrolledNearTop = container.scrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } @@ -657,16 +619,7 @@ export function useChatSessionState({ const didLoad = await loadOlderMessages(container); if (didLoad) topLoadLockRef.current = true; } - }, [activeScrollKey, isNearBottom, loadOlderMessages]); - - useLayoutEffect(() => { - if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; - const { height, top } = pendingScrollRestoreRef.current; - const container = scrollContainerRef.current; - const newScrollHeight = container.scrollHeight; - container.scrollTop = top + Math.max(newScrollHeight - height, 0); - pendingScrollRestoreRef.current = null; - }, [chatMessages.length]); + }, [activeScrollKey, getIsPaused, loadOlderMessages]); // Reset scroll/pagination state on session change useLayoutEffect(() => { @@ -681,12 +634,11 @@ export function useChatSessionState({ setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); } topLoadLockRef.current = false; - pendingScrollRestoreRef.current = null; setIsUserScrolledUp(Boolean( savedScrollPosition - && savedScrollPosition.distanceFromBottom > CONVERSATION_SCROLL_BOTTOM_THRESHOLD + && !(savedScrollPosition.following ?? savedScrollPosition.distanceFromBottom <= CONVERSATION_SCROLL_BOTTOM_THRESHOLD) )); - }, [activeScrollKey]); + }, [activeScrollKey, setIsUserScrolledUp]); useLayoutEffect(() => { const pendingRestore = pendingConversationScrollRestoreRef.current; @@ -706,17 +658,28 @@ export function useChatSessionState({ container.scrollHeight, container.clientHeight, ); + captureReadingAnchor(); pendingConversationScrollRestoreRef.current = null; pendingInitialScrollRef.current = false; - }, [activeScrollKey, chatMessages.length, isLoadingSessionMessages]); + }, [activeScrollKey, chatMessages.length, isLoadingSessionMessages, captureReadingAnchor]); + + useLayoutEffect(() => { + const container = scrollContainerRef.current; + if (!activeScrollKey || !container || pendingConversationScrollRestoreRef.current) return; + conversationScrollPositionsRef.current.set(activeScrollKey, { + top: container.scrollTop, + distanceFromBottom: Math.max(0, container.scrollHeight - container.scrollTop - container.clientHeight), + following: !isUserScrolledUp, + }); + }, [activeScrollKey, isUserScrolledUp]); // Initial scroll to bottom useEffect(() => { if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return; if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; } pendingInitialScrollRef.current = false; - if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200); - }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); + if (!searchScrollActiveRef.current) scheduleScrollToBottom(); + }, [chatMessages.length, isLoadingSessionMessages, scheduleScrollToBottom]); // Main session loading effect — store-based useEffect(() => { @@ -910,7 +873,7 @@ export function useChatSessionState({ }); if (autoScrollToBottom && isNearBottom()) { - setTimeout(() => scrollToBottom(), 200); + scheduleScrollToBottom(); } } } catch (error) { @@ -923,7 +886,7 @@ export function useChatSessionState({ autoScrollToBottom, externalMessageUpdate, isNearBottom, - scrollToBottom, + scheduleScrollToBottom, selectedProject, selectedSession, sessionRequestParams, @@ -950,11 +913,30 @@ export function useChatSessionState({ }, [pendingViewSessionRef, selectedSession?.id]); // Scroll to search target + const hasChatMessages = chatMessages.length > 0; useEffect(() => { - if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; + if (!searchTarget || !hasChatMessages || isLoadingSessionMessages) return; const target = searchTarget; - setSearchTarget(null); + setIsUserScrolledUp(true); + let cancelled = false; + const timers = new Set>(); + const container = scrollContainerRef.current; + const later = (callback: () => void, delay: number) => { + const timer = setTimeout(() => { timers.delete(timer); if (!cancelled) callback(); }, delay); + timers.add(timer); + }; + const cancelNavigation = () => { + cancelled = true; + searchScrollActiveRef.current = false; + setSearchTarget(null); + }; + const cancelOnKey = (event: KeyboardEvent) => { + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) cancelNavigation(); + }; + container?.addEventListener('wheel', cancelNavigation, { passive: true }); + container?.addEventListener('touchmove', cancelNavigation, { passive: true }); + container?.addEventListener('keydown', cancelOnKey); const scrollToTarget = async () => { if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { @@ -968,6 +950,7 @@ export function useChatSessionState({ limit: null, offset: 0, }); + if (cancelled) return; if (slot) { setHasMoreMessages(false); setTotalMessages(slot.total); @@ -982,9 +965,11 @@ export function useChatSessionState({ } } } + if (cancelled) return; setVisibleMessageCount(Infinity); const findAndScroll = (retriesLeft: number) => { + if (cancelled) return; const container = scrollContainerRef.current; if (!container) return; @@ -1019,18 +1004,28 @@ export function useChatSessionState({ targetElement.classList.add('search-highlight-flash'); setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000); searchScrollActiveRef.current = false; + setSearchTarget(null); } else if (retriesLeft > 0) { - setTimeout(() => findAndScroll(retriesLeft - 1), 200); + later(() => findAndScroll(retriesLeft - 1), 200); } else { searchScrollActiveRef.current = false; + setSearchTarget(null); } }; - setTimeout(() => findAndScroll(15), 150); + later(() => findAndScroll(15), 150); }; scrollToTarget(); - }, [chatMessages.length, isLoadingSessionMessages, searchTarget, selectedProject, selectedSession, sessionRequestParams, sessionStore]); + return () => { + cancelled = true; + timers.forEach(clearTimeout); + container?.removeEventListener('wheel', cancelNavigation); + container?.removeEventListener('touchmove', cancelNavigation); + container?.removeEventListener('keydown', cancelOnKey); + searchScrollActiveRef.current = false; + }; + }, [hasChatMessages, isLoadingSessionMessages, searchTarget, selectedProject, selectedSession, sessionRequestParams, sessionStore, setIsUserScrolledUp]); useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { @@ -1058,47 +1053,15 @@ export function useChatSessionState({ fetchInitialTokenUsage(); }, [sessionIsReadOnly, selectedProject, selectedSession?.id]); - const visibleMessages = useMemo(() => { - return selectVisibleMessages(chatMessages, visibleMessageCount); - }, [chatMessages, visibleMessageCount]); - const streamContentKey = useMemo( - () => getStreamContentKey(visibleMessages), - [visibleMessages], - ); - const activityContentKey = getStreamContentKey(activityMessages); - - useEffect(() => { - if (!autoScrollToBottom && scrollContainerRef.current) { - const container = scrollContainerRef.current; - scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop }; - } - }); - - useEffect(() => { - if (!scrollContainerRef.current || chatMessages.length === 0) return; - if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return; - if (searchScrollActiveRef.current) return; - - if (shouldFollowConversationScroll(autoScrollToBottom, isUserScrolledUp)) { - scheduleScrollToBottom(); - return; - } - - const container = scrollContainerRef.current; - const prevHeight = scrollPositionRef.current.height; - const prevTop = scrollPositionRef.current.top; - const newHeight = container.scrollHeight; - const heightDiff = newHeight - prevHeight; - if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff; - }, [ - autoScrollToBottom, - activityContentKey, - chatMessages.length, - isLoadingMoreMessages, - isUserScrolledUp, - scheduleScrollToBottom, - streamContentKey, - ]); + // Appending a streamed tool/message must not evict the history being read. + const visibleWindowStartRef = useRef({ scope: activeScrollKey, start: 0 }); + if (!isUserScrolledUp || visibleWindowStartRef.current.scope !== activeScrollKey) { + visibleWindowStartRef.current = { scope: activeScrollKey, start: Math.max(0, chatMessages.length - visibleMessageCount) }; + } + const effectiveVisibleCount = isUserScrolledUp + ? Math.max(visibleMessageCount, chatMessages.length - visibleWindowStartRef.current.start) + : visibleMessageCount; + const visibleMessages = useMemo(() => selectVisibleMessages(chatMessages, effectiveVisibleCount), [chatMessages, effectiveVisibleCount]); useEffect(() => { const container = scrollContainerRef.current; @@ -1187,9 +1150,8 @@ export function useChatSessionState({ setIsLoadingAllMessages(true); setShowLoadAllOverlay(true); - const container = scrollContainerRef.current; - const previousScrollHeight = container ? container.scrollHeight : 0; - const previousScrollTop = container ? container.scrollTop : 0; + const requestScrollKey = activeScrollKey; + captureReadingAnchor(); try { const slot = await sessionStore.fetchFromServer(requestSessionId, { @@ -1201,13 +1163,9 @@ export function useChatSessionState({ offset: 0, }); - if (currentSessionId !== requestSessionId) return; + if (activeScrollKeyRef.current !== requestScrollKey) return; if (slot) { - if (container) { - pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; - } - setHasMoreMessages(false); setTotalMessages(slot.total); messagesOffsetRef.current = slot.total; @@ -1233,14 +1191,16 @@ export function useChatSessionState({ selectedSession, selectedProject, isLoadingAllMessages, - currentSessionId, + activeScrollKey, + captureReadingAnchor, sessionRequestParams, sessionStore, ]); const loadEarlierMessages = useCallback(() => { + pauseScrollFollowing(); setVisibleMessageCount((prev) => prev + 100); - }, []); + }, [pauseScrollFollowing]); return { chatMessages, @@ -1285,6 +1245,8 @@ export function useChatSessionState({ scrollContainerRef, scrollToBottom, scrollToBottomAndReset, + scheduleScrollToBottom, + pauseScrollFollowing, isNearBottom, handleScroll, }; diff --git a/ui/src/components/chat/hooks/useScrollFollow.test.tsx b/ui/src/components/chat/hooks/useScrollFollow.test.tsx new file mode 100644 index 000000000..4a7b9a8d4 --- /dev/null +++ b/ui/src/components/chat/hooks/useScrollFollow.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { useRef } from 'react'; +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useScrollFollow } from './useScrollFollow'; + +let frames: Map; +let nextFrame: number; +let observers: Set; +let api: ReturnType; + +function Harness({ enabled = true, scope = 'one', version = 0 }: { enabled?: boolean; scope?: string; version?: number }) { + const ref = useRef(null); + api = useScrollFollow({ containerRef: ref, enabled, scopeKey: scope, contentSelector: '[data-chat-scroll-content]' }); + return
+
Earlier
Reading {version}
+
; +} + +function setup() { + const view = render(); + const node = view.getByTestId('viewport'); + const metrics = { top: 0, height: 1000, viewport: 200 }; + Object.defineProperties(node, { + scrollTop: { configurable: true, get: () => metrics.top, set: (value: number) => { metrics.top = Math.max(0, Math.min(value, metrics.height - metrics.viewport)); } }, + scrollHeight: { configurable: true, get: () => metrics.height }, + clientHeight: { configurable: true, get: () => metrics.viewport }, + }); + flush(); + fireEvent.scroll(node); + return { view, node, metrics }; +} +function flush() { + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach((callback) => callback(performance.now())); + }); +} +function resize() { act(() => observers.forEach((callback) => callback([], {} as ResizeObserver))); } + +beforeEach(() => { + frames = new Map(); nextFrame = 0; observers = new Set(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal('ResizeObserver', class { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { this.callback = callback; } + observe() { observers.add(this.callback); } + disconnect() { observers.delete(this.callback); } + }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('reader-owned scroll following', () => { + it('cancels a queued follow on a small upward gesture, before React commits', () => { + const { node, metrics, view } = setup(); + resize(); + fireEvent.wheel(node, { deltaY: -5 }); + node.scrollTop -= 5; + fireEvent.scroll(node); + metrics.height += 50; + view.rerender(); + resize(); flush(); + expect(node.scrollTop).toBe(795); + expect(api.isPaused).toBe(true); + }); + + it('resumes only when the reader scrolls down to the actual bottom', () => { + const { node, metrics } = setup(); + fireEvent.wheel(node, { deltaY: -30 }); + node.scrollTop = 770; fireEvent.scroll(node); + node.scrollTop = 790; fireEvent.scroll(node); + resize(); flush(); + expect(node.scrollTop).toBe(790); + node.scrollTop = 800; fireEvent.scroll(node); + metrics.height += 40; + resize(); flush(); + expect(node.scrollTop).toBe(840); + expect(api.isPaused).toBe(false); + }); + + it('does not resume after layout shrink clamps the paused viewport to the bottom', () => { + const { node, metrics } = setup(); + fireEvent.wheel(node, { deltaY: -200 }); node.scrollTop = 600; fireEvent.scroll(node); + metrics.height = 800; fireEvent.scroll(node); + metrics.height = 1100; resize(); flush(); + expect(node.scrollTop).toBe(600); + expect(api.isPaused).toBe(true); + }); + + it('pauses the conversation while an inner thinking viewport is being read', () => { + const { node, metrics } = setup(); + const inner = document.createElement('div'); inner.dataset.streamScrollViewport = ''; node.append(inner); + fireEvent.wheel(inner, { deltaY: -10 }); + fireEvent.wheel(inner, { deltaY: 10 }); + metrics.height += 100; resize(); flush(); + expect(node.scrollTop).toBe(800); + expect(api.isPaused).toBe(true); + act(() => api.scrollToBottom()); + expect(node.scrollTop).toBe(900); + }); + + it.each(['keyboard', 'touch', 'scrollbar'])('cancels pending scroll for %s input', (kind) => { + const { node, metrics } = setup(); resize(); + if (kind === 'keyboard') fireEvent.keyDown(node, { key: 'PageUp' }); + if (kind === 'scrollbar') fireEvent.pointerDown(node); + if (kind === 'touch') { + fireEvent.touchStart(node, { touches: [{ clientY: 20 }] }); + fireEvent.touchMove(node, { touches: [{ clientY: 40 }] }); + } + node.scrollTop = 700; fireEvent.scroll(node); + metrics.height += 30; resize(); flush(); + expect(node.scrollTop).toBe(700); + }); + + it('preserves the visible message when history grows above and streaming grows below', () => { + const { view, node, metrics } = setup(); + let addedHistory = 0; + const rows = node.querySelectorAll('[data-message-key]'); + rows.forEach((row, index) => { + row.getBoundingClientRect = () => ({ top: addedHistory + index * 300 - metrics.top, bottom: addedHistory + (index + 1) * 300 - metrics.top }) as DOMRect; + }); + fireEvent.wheel(node, { deltaY: -450 }); node.scrollTop = 350; fireEvent.scroll(node); + addedHistory = 100; metrics.height += 250; + view.rerender(); resize(); flush(); + expect(node.scrollTop).toBe(450); + expect(rows[1].getBoundingClientRect().top).toBe(-50); + }); + + it('cancels queued work when disabled or unmounted', () => { + const { view, node, metrics } = setup(); + resize(); metrics.height += 100; + view.rerender(); flush(); + expect(node.scrollTop).toBe(800); + view.unmount(); + expect(frames.size).toBe(0); + }); +}); diff --git a/ui/src/components/chat/hooks/useScrollFollow.ts b/ui/src/components/chat/hooks/useScrollFollow.ts new file mode 100644 index 000000000..0cca2552b --- /dev/null +++ b/ui/src/components/chat/hooks/useScrollFollow.ts @@ -0,0 +1,208 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; + +const BOTTOM_EPSILON = 2; + +type ReadingAnchor = { key: string; offset: number }; + +/** A scroll gesture owns the viewport until the reader explicitly returns to its end. */ +export function useScrollFollow({ + containerRef, + enabled = true, + scopeKey, + contentKey, + contentSelector, + canFollow, +}: { + containerRef: RefObject; + enabled?: boolean; + scopeKey?: string | null; + contentKey?: unknown; + contentSelector?: string; + canFollow?: () => boolean; +}) { + const [isPaused, setIsPaused] = useState(false); + const pausedRef = useRef(false); + const optionsRef = useRef({ enabled, canFollow }); + optionsRef.current = { enabled, canFollow }; + const frameRef = useRef(null); + const anchorRef = useRef(null); + const metricsRef = useRef({ top: 0, height: 0, viewport: 0 }); + const programmaticTopRef = useRef(null); + + const cancelFollow = useCallback(() => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }, []); + + const rememberMetrics = useCallback(() => { + const node = containerRef.current; + if (node) metricsRef.current = { top: node.scrollTop, height: node.scrollHeight, viewport: node.clientHeight }; + }, [containerRef]); + + const captureAnchor = useCallback(() => { + const node = containerRef.current; + if (!node || !contentSelector) return; + const top = node.getBoundingClientRect().top; + const row = Array.from(node.querySelectorAll('[data-message-key]')) + .find((item) => item.getBoundingClientRect().bottom > top); + anchorRef.current = row ? { key: row.dataset.messageKey!, offset: row.getBoundingClientRect().top - top } : null; + if (row) node.dataset.readingAnchorKey = row.dataset.messageKey; + else delete node.dataset.readingAnchorKey; + }, [containerRef, contentSelector]); + + const restoreAnchor = useCallback(() => { + const node = containerRef.current; + const anchor = anchorRef.current; + if (!node || !anchor) return; + const row = Array.from(node.querySelectorAll('[data-message-key]')) + .find((item) => item.dataset.messageKey === anchor.key); + if (!row) return; + const delta = row.getBoundingClientRect().top - node.getBoundingClientRect().top - anchor.offset; + if (Math.abs(delta) > 0.5) { + node.scrollTop += delta; + programmaticTopRef.current = node.scrollTop; + } + rememberMetrics(); + }, [containerRef, rememberMetrics]); + + const setPaused = useCallback((paused: boolean) => { + pausedRef.current = paused; + setIsPaused(paused); + if (paused) { + cancelFollow(); + captureAnchor(); + } else { + anchorRef.current = null; + if (containerRef.current) delete containerRef.current.dataset.readingAnchorKey; + } + }, [cancelFollow, captureAnchor, containerRef]); + + const writeBottom = useCallback(() => { + const node = containerRef.current; + if (!node) return; + node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + programmaticTopRef.current = node.scrollTop; + rememberMetrics(); + }, [containerRef, rememberMetrics]); + + const scheduleFollow = useCallback(() => { + if (frameRef.current !== null) return; + const allowed = () => !pausedRef.current && optionsRef.current.enabled && (optionsRef.current.canFollow?.() ?? true); + if (!allowed()) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + // Input may have arrived after the resize notification queued this frame. + if (allowed()) writeBottom(); + }); + }, [writeBottom]); + + const getIsPaused = useCallback(() => pausedRef.current, []); + const pause = useCallback(() => setPaused(true), [setPaused]); + + const scrollToBottom = useCallback(() => { + cancelFollow(); + setPaused(false); + writeBottom(); + scheduleFollow(); + }, [cancelFollow, setPaused, writeBottom, scheduleFollow]); + + useLayoutEffect(() => { + cancelFollow(); + anchorRef.current = null; + if (containerRef.current) delete containerRef.current.dataset.readingAnchorKey; + programmaticTopRef.current = null; + pausedRef.current = false; + setIsPaused(false); + rememberMetrics(); + return cancelFollow; + }, [scopeKey, cancelFollow, containerRef, rememberMetrics]); + + useLayoutEffect(() => { + const node = containerRef.current; + if (!node) return; + let touchY: number | null = null; + const atBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight <= BOTTOM_EPSILON; + const isNested = (target: EventTarget | null) => target instanceof Element + && target.closest('[data-stream-scroll-viewport]') !== node; + const wheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + if (event.deltaY < 0) setPaused(true); + else if (event.deltaY > 0 && !isNested(event.target) && atBottom()) scrollToBottom(); + }; + const touchStart = (event: TouchEvent) => { touchY = event.touches[0]?.clientY ?? null; }; + const touchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY ?? null; + if (nextY !== null && touchY !== null && nextY > touchY) setPaused(true); + else if (nextY !== null && touchY !== null && nextY < touchY && !isNested(event.target) && atBottom()) scrollToBottom(); + touchY = nextY; + }; + const keyDown = (event: KeyboardEvent) => { + const target = event.target; + if (target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"]')) return; + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) setPaused(true); + else if (event.key === 'End' && !isNested(target)) scrollToBottom(); + }; + const pointerDown = (event: PointerEvent) => { + // Scrollbar dragging has no wheel event; pause before the next queued frame. + if (event.target === node) setPaused(true); + }; + const scroll = () => { + const previous = metricsRef.current; + const top = node.scrollTop; + if (programmaticTopRef.current !== null && Math.abs(top - programmaticTopRef.current) < 0.5) { + programmaticTopRef.current = null; + rememberMetrics(); + return; + } + programmaticTopRef.current = null; + const layoutChanged = node.scrollHeight !== previous.height || node.clientHeight !== previous.viewport; + if (!layoutChanged) { + if (top < previous.top) setPaused(true); + else if (top > previous.top && atBottom()) setPaused(false); + if (pausedRef.current || !optionsRef.current.enabled) captureAnchor(); + } + rememberMetrics(); + }; + // Capture upward intent inside nested reasoning too: the conversation must + // not move underneath someone reading that reasoning when a tool arrives. + node.addEventListener('wheel', wheel, { capture: true, passive: true }); + node.addEventListener('touchstart', touchStart, { capture: true, passive: true }); + node.addEventListener('touchmove', touchMove, { capture: true, passive: true }); + node.addEventListener('keydown', keyDown, true); + node.addEventListener('pointerdown', pointerDown, true); + node.addEventListener('scroll', scroll, { passive: true }); + rememberMetrics(); + return () => { + node.removeEventListener('wheel', wheel, true); + node.removeEventListener('touchstart', touchStart, true); + node.removeEventListener('touchmove', touchMove, true); + node.removeEventListener('keydown', keyDown, true); + node.removeEventListener('pointerdown', pointerDown, true); + node.removeEventListener('scroll', scroll); + }; + }, [containerRef, scopeKey, contentKey, captureAnchor, rememberMetrics, scrollToBottom, setPaused]); + + useLayoutEffect(() => { + const node = containerRef.current; + const content = contentSelector ? node?.querySelector(contentSelector) : node?.firstElementChild; + if (!node || !content || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + if (pausedRef.current || !optionsRef.current.enabled) restoreAnchor(); + else scheduleFollow(); + }); + observer.observe(content); + observer.observe(node); + return () => observer.disconnect(); + }, [containerRef, contentSelector, scopeKey, contentKey, restoreAnchor, scheduleFollow]); + + useLayoutEffect(() => { + if (pausedRef.current || !enabled) { + cancelFollow(); + restoreAnchor(); + if (!anchorRef.current) captureAnchor(); + } else scheduleFollow(); + }); + + return { isPaused, setPaused, getIsPaused, pause, scrollToBottom, scheduleFollow, cancelFollow, captureAnchor }; +} diff --git a/ui/src/components/chat/types/types.ts b/ui/src/components/chat/types/types.ts index 1169ed4e3..802af3aac 100644 --- a/ui/src/components/chat/types/types.ts +++ b/ui/src/components/chat/types/types.ts @@ -85,6 +85,7 @@ export interface SubagentChildTool { export interface ChatMessage { id?: string; + renderKey?: string; entryId?: string; type: string; content?: string; diff --git a/ui/src/components/chat/utils/messageKeys.ts b/ui/src/components/chat/utils/messageKeys.ts index 410161fed..ec8e00f93 100644 --- a/ui/src/components/chat/utils/messageKeys.ts +++ b/ui/src/components/chat/utils/messageKeys.ts @@ -11,6 +11,7 @@ const toMessageKeyPart = (value: unknown): string | null => { export const getIntrinsicMessageKey = (message: ChatMessage): string | null => { const candidates = [ + message.renderKey, message.id, message.messageId, message.toolId, diff --git a/ui/src/components/chat/view/subcomponents/Markdown.tsx b/ui/src/components/chat/view/subcomponents/Markdown.tsx index 29619dddf..d0ea65f97 100644 --- a/ui/src/components/chat/view/subcomponents/Markdown.tsx +++ b/ui/src/components/chat/view/subcomponents/Markdown.tsx @@ -76,7 +76,7 @@ export function Markdown({ [onFileOpen], ); const remarkPlugins = useMemo(() => { - if (isStreaming) return [remarkGfm]; + if (isStreaming) return [remarkGfm, remarkMath]; if (artifactFiles === undefined) return [remarkGfm, remarkMath]; return [remarkGfm, remarkMath, createRemarkArtifactFileTextPlugin(artifactFiles)]; }, [artifactFiles, isStreaming]); @@ -92,7 +92,7 @@ export function Markdown({
{content} diff --git a/ui/src/i18n/locales/en/chat.json b/ui/src/i18n/locales/en/chat.json index e733f433d..1be520d68 100644 --- a/ui/src/i18n/locales/en/chat.json +++ b/ui/src/i18n/locales/en/chat.json @@ -472,6 +472,9 @@ "loadingAll": "Loading all messages...", "allLoaded": "All messages loaded", "perfWarning": "All messages loaded — scrolling may be slower. Click \"Scroll to bottom\" to restore performance." + }, + "scroll": { + "returnToLatest": "Back to latest" } }, "loading": "Loading...", diff --git a/ui/src/i18n/locales/zh-CN/chat.json b/ui/src/i18n/locales/zh-CN/chat.json index d3e72f22e..b1a86a9f9 100644 --- a/ui/src/i18n/locales/zh-CN/chat.json +++ b/ui/src/i18n/locales/zh-CN/chat.json @@ -454,6 +454,9 @@ "loadingAll": "正在加载全部消息...", "allLoaded": "全部消息已加载", "perfWarning": "已加载全部消息 - 滚动可能变慢。点击「滚动到底部」恢复性能。" + }, + "scroll": { + "returnToLatest": "回到最新" } }, "loading": "加载中...", diff --git a/ui/src/stores/useSessionStore.renderKeys.test.tsx b/ui/src/stores/useSessionStore.renderKeys.test.tsx new file mode 100644 index 000000000..2c00b0432 --- /dev/null +++ b/ui/src/stores/useSessionStore.renderKeys.test.tsx @@ -0,0 +1,44 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { normalizedToChatMessages } from '../components/chat/hooks/useChatMessages'; +import { getIntrinsicMessageKey } from '../components/chat/utils/messageKeys'; +import { inheritMessageRenderKeys, useSessionStore, type NormalizedMessage } from './useSessionStore'; + +afterEach(cleanup); +const msg = (id: string, runId = 'run'): NormalizedMessage => ({ id, sessionId: 'session', runId, kind: 'text', role: 'assistant', content: 'Same content', timestamp: '2026-09-05', provider: 'pilotdeck' }); +describe('streaming presentation identity', () => { + it.each(['thinking', 'text'])('keeps subagent %s identity and clears the live flag on completion', (kind) => { + const { result } = renderHook(() => useSessionStore()); + const update = kind === 'thinking' ? result.current.updateSubagentDetailThinking : result.current.updateSubagentDetailStreaming; + const finalize = kind === 'thinking' ? result.current.finalizeSubagentDetailThinking : result.current.finalizeSubagentDetailStreaming; + act(() => update('session', 'child', 'Subagent block', 'pilotdeck')); + const before = normalizedToChatMessages(result.current.getSubagentDetailMessages('session', 'child'))[0]; + expect(before.isStreaming).toBe(true); + act(() => finalize('session', 'child')); + const after = normalizedToChatMessages(result.current.getSubagentDetailMessages('session', 'child'))[0]; + expect(after.isStreaming).toBeFalsy(); + expect(getIntrinsicMessageKey(after)).toBe(getIntrinsicMessageKey(before)); + }); + it.each(['thinking', 'text'])('keeps the %s React key through finalization and allocates another for the next block', (kind) => { + const { result } = renderHook(() => useSessionStore()); + const update = kind === 'thinking' ? result.current.updateStreamingThinking : result.current.updateStreaming; + const finalize = kind === 'thinking' ? result.current.finalizeStreamingThinking : result.current.finalizeStreaming; + act(() => update('session', 'First block', 'pilotdeck', 'run')); + const before = normalizedToChatMessages(result.current.getMessages('session'))[0]; + act(() => finalize('session', 'run')); + const after = normalizedToChatMessages(result.current.getMessages('session'))[0]; + expect(after.id).not.toBe(before.id); + expect(getIntrinsicMessageKey(after)).toBe(getIntrinsicMessageKey(before)); + act(() => update('session', 'Second block', 'pilotdeck', 'run')); + const second = normalizedToChatMessages(result.current.getMessages('session'))[1]; + expect(getIntrinsicMessageKey(second)).not.toBe(getIntrinsicMessageKey(before)); + }); + it('preserves keys when the server confirms content, without crossing runs or duplicating keys', () => { + const previous = [{ ...msg('live'), renderKey: 'render-one' }]; + expect(inheritMessageRenderKeys(previous, [msg('server')])[0].renderKey).toBe('render-one'); + expect(inheritMessageRenderKeys(previous, [msg('server', 'other-run')])[0].renderKey).toBeUndefined(); + const duplicates = inheritMessageRenderKeys(previous, [msg('server'), { ...msg('live'), renderKey: 'render-one' }]); + expect(duplicates[0].renderKey).toBeUndefined(); + expect(duplicates[1].renderKey).toBe('render-one'); + }); +}); diff --git a/ui/src/stores/useSessionStore.ts b/ui/src/stores/useSessionStore.ts index 9f825b1f4..05aac4282 100644 --- a/ui/src/stores/useSessionStore.ts +++ b/ui/src/stores/useSessionStore.ts @@ -47,6 +47,8 @@ export interface CompactProgress { export interface NormalizedMessage { id: string; + /** Stable UI identity across streaming finalization. */ + renderKey?: string; sessionId: string; timestamp: string; provider: SessionProvider; @@ -1041,6 +1043,7 @@ export function upsertRealtimeMessages( const previousKey = getUpsertKey(updated[duplicateAssistantTextIndex]); updated[duplicateAssistantTextIndex] = { ...message, + renderKey: updated[duplicateAssistantTextIndex].renderKey ?? message.renderKey, serverTailIdAtStart: message.serverTailIdAtStart ?? updated[duplicateAssistantTextIndex].serverTailIdAtStart, }; indexByKey.delete(previousKey); @@ -1055,10 +1058,12 @@ export function upsertRealtimeMessages( } else { const existingTailId = updated[existingIndex].serverTailIdAtStart; const existingHistoryPending = updated[existingIndex].serverHistoryPendingAtStart; - updated[existingIndex] = existingTailId === undefined && existingHistoryPending === undefined + const renderKey = updated[existingIndex].renderKey; + updated[existingIndex] = existingTailId === undefined && existingHistoryPending === undefined && !renderKey ? message : { ...message, + ...(renderKey ? { renderKey } : {}), ...(existingTailId !== undefined ? { serverTailIdAtStart: existingTailId } : {}), ...(existingHistoryPending !== undefined ? { serverHistoryPendingAtStart: existingHistoryPending } @@ -1079,6 +1084,34 @@ function findLatestToolResultIndex(messages: NormalizedMessage[], toolId: string return -1; } +/** Carry UI identity through the server's confirmation of a displayed stream. */ +export function inheritMessageRenderKeys(previous: NormalizedMessage[], next: NormalizedMessage[]): NormalizedMessage[] { + const candidates = previous.filter((message) => message.renderKey); + if (candidates.length === 0) return next; + const used = new Set(next.flatMap((message) => message.renderKey ? [message.renderKey] : [])); + let changed = false; + const result = next.map((message) => { + if (message.renderKey) { + used.add(message.renderKey); + return message; + } + const match = candidates.find((candidate) => { + if (!candidate.renderKey || used.has(candidate.renderKey)) return false; + if (candidate.id === message.id) return true; + const turn = getMessageTurnId(candidate); + if (!turn || turn !== getMessageTurnId(message)) return false; + const sameKind = candidate.kind === message.kind + || (candidate.kind === 'stream_delta' && message.kind === 'text' && message.role === 'assistant'); + return sameKind && Boolean(candidate.content) && candidate.content === message.content; + }); + if (!match?.renderKey) return message; + used.add(match.renderKey); + changed = true; + return { ...message, renderKey: match.renderKey }; + }); + return changed ? result : next; +} + /** * Recompute slot.merged only when the input arrays have actually changed * (by reference). Returns true if merged was recomputed. @@ -1089,14 +1122,14 @@ function recomputeMergedIfNeeded(slot: SessionSlot): boolean { } slot._lastServerRef = slot.serverMessages; slot._lastRealtimeRef = slot.realtimeMessages; - slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages); + slot.merged = inheritMessageRenderKeys(slot.merged, computeMerged(slot.serverMessages, slot.realtimeMessages)); return true; } function forceRecomputeMerged(slot: SessionSlot): void { slot._lastServerRef = slot.serverMessages; slot._lastRealtimeRef = slot.realtimeMessages; - slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages); + slot.merged = inheritMessageRenderKeys(slot.merged, computeMerged(slot.serverMessages, slot.realtimeMessages)); } function streamingKey(sessionId: string, runId?: string): string { @@ -1570,6 +1603,7 @@ export function useSessionStore() { ...current, { id: streamId, + renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, sessionId, timestamp: new Date().toISOString(), provider: msgProvider, @@ -1633,6 +1667,7 @@ export function useSessionStore() { ...current, { id: streamId, + renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, sessionId, timestamp: new Date().toISOString(), provider: msgProvider, @@ -1874,6 +1909,7 @@ export function useSessionStore() { : null; const msg: NormalizedMessage = { id: streamId, + renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, sessionId, timestamp: new Date().toISOString(), provider: msgProvider, @@ -1943,6 +1979,7 @@ export function useSessionStore() { : null; const msg: NormalizedMessage = { id: streamId, + renderKey: `${streamId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, sessionId, timestamp: new Date().toISOString(), provider: msgProvider, From 85d0b009446d9b729c7e6264e745c5aa649926df Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 15:07:48 +0800 Subject: [PATCH 2/3] fix(ui): preserve reading state through streaming and completion --- ui/e2e/fixtures/streaming-lifecycle.html | 2 + ui/e2e/fixtures/streaming-lifecycle.jsx | 107 ++++++++++ ui/e2e/fixtures/streaming-scroll.jsx | 4 +- ui/e2e/streaming-lifecycle.spec.mjs | 168 +++++++++++++++ ui/e2e/streaming-scroll.config.mjs | 2 +- ui/src/components/chat-v2/ChatInterfaceV2.tsx | 4 +- ui/src/components/chat-v2/MessageRowV2.tsx | 10 +- ui/src/components/chat-v2/MessagesPaneV2.tsx | 6 +- .../chat-v2/StreamingScrollViewport.tsx | 4 +- .../chat-v2/SubagentDetailMessageFlow.tsx | 4 +- .../chat-v2/SubagentDetailModal.tsx | 5 +- .../chat-v2/processGrouping.test.ts | 11 + ui/src/components/chat-v2/processGrouping.ts | 1 - .../chat-v2/useChatHistorySearch.ts | 124 ++++++----- .../useSubagentMessages.refresh.test.tsx | 63 ++++++ .../chat-v2/useSubagentMessages.test.ts | 24 ++- .../components/chat-v2/useSubagentMessages.ts | 78 +++++-- .../chat/hooks/useChatSessionState.ts | 52 +++-- .../chat/hooks/useScrollFollow.test.tsx | 71 +++++++ .../components/chat/hooks/useScrollFollow.ts | 193 ++++++++++++++---- 20 files changed, 778 insertions(+), 155 deletions(-) create mode 100644 ui/e2e/fixtures/streaming-lifecycle.html create mode 100644 ui/e2e/fixtures/streaming-lifecycle.jsx create mode 100644 ui/e2e/streaming-lifecycle.spec.mjs create mode 100644 ui/src/components/chat-v2/useSubagentMessages.refresh.test.tsx diff --git a/ui/e2e/fixtures/streaming-lifecycle.html b/ui/e2e/fixtures/streaming-lifecycle.html new file mode 100644 index 000000000..cd845359f --- /dev/null +++ b/ui/e2e/fixtures/streaming-lifecycle.html @@ -0,0 +1,2 @@ + +Streaming lifecycle regression
diff --git a/ui/e2e/fixtures/streaming-lifecycle.jsx b/ui/e2e/fixtures/streaming-lifecycle.jsx new file mode 100644 index 000000000..80b74d2ba --- /dev/null +++ b/ui/e2e/fixtures/streaming-lifecycle.jsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { FindShortcutProvider } from '../../src/contexts/FindShortcutContext'; +import MessagesPane from '../../src/components/chat-v2/MessagesPaneV2'; +import SubagentModal from '../../src/components/chat-v2/SubagentDetailModal'; +import SubagentFlow from '../../src/components/chat-v2/SubagentDetailMessageFlow'; +import { normalizedToChatMessages } from '../../src/components/chat/hooks/useChatMessages'; +import { useSubagentMessages } from '../../src/components/chat-v2/useSubagentMessages'; +import { useChatSessionState } from '../../src/components/chat/hooks/useChatSessionState'; +import { useSessionStore } from '../../src/stores/useSessionStore'; +import '../../src/index.css'; + +const params = new URLSearchParams(location.search); +const project = { name: 'fixture', path: '/fixture' }; +const sessions = [{ id: 'a', isReadOnly: true }, { id: 'b', isReadOnly: true }]; +const noop = () => {}; +const diff = () => []; +const normalized = (sid, index) => ({ + id: `${sid}-${index}`, sessionId: sid, timestamp: '2026-09-05T00:00:00Z', + provider: 'pilotdeck', kind: 'text', role: index % 2 ? 'assistant' : 'user', + content: `Session ${sid} message ${index}.\n\nA paragraph for reading.`, +}); +const count = Number(params.get('count') || 240); +const data = Object.fromEntries(sessions.map((session) => [ + session.id, Array.from({ length: count }, (_, i) => normalized(session.id, i)), +])); +const mockStore = { + setActiveSession: noop, + getMessages: (id) => data[id] || [], + has: () => true, + isStale: () => false, + fetchFromServer: async (id) => ({ + status: 'idle', hasMore: false, total: data[id].length, serverMessages: data[id], + }), +}; + +function SessionFixture() { + const [session, setSession] = useState(sessions[0]); + const [auto, setAuto] = useState(params.get('auto') !== 'false'); + const [, tick] = useState(0); + const pending = useRef(null); + const chat = useChatSessionState({ + selectedProject: project, selectedSession: session, ws: null, sendMessage: noop, + autoScrollToBottom: auto, resetStreamingState: noop, pendingViewSessionRef: pending, sessionStore: mockStore, + }); + window.streamLifecycle = { + chat, setSession: (index) => setSession(sessions[index]), setAuto, + append: (n = 1) => { + data[session.id] = [...data[session.id], ...Array.from({ length: n }, (_, i) => normalized(session.id, data[session.id].length + i))]; + tick((value) => value + 1); + }, + }; + return +
+ +
+
; +} + +function ChildFixture({ direct = false }) { + const store = useSessionStore(); + const [status, setStatus] = useState('running'); + useEffect(() => store.setActiveSession('s'), [store]); + const detail = useSubagentMessages(direct ? null : 's', direct ? null : 'child', undefined, store, status); + window.streamLifecycle = { + store, detail, status, + think: (text) => store.updateSubagentDetailThinking('s', 'child', text, 'pilotdeck'), + text: (text) => store.updateSubagentDetailStreaming('s', 'child', text, 'pilotdeck'), + finish: () => { + store.finalizeSubagentDetailThinking('s', 'child'); + store.finalizeSubagentDetailStreaming('s', 'child'); + setStatus('completed'); + }, + }; + return + {direct ?
+ +
: } +
; +} + +function TranscriptFixture() { + const [state, set] = useState({ messages: [], working: true, activities: [], showThinking: true }); + const ref = useRef(null); + window.streamLifecycle = { ...state, set: (update) => set((value) => ({ ...value, ...update })) }; + return +
+ +
+
; +} + +createRoot(document.getElementById('root')).render( + params.has('child-direct') ? : params.has('child') ? + : params.has('transcript') ? : , +); diff --git a/ui/e2e/fixtures/streaming-scroll.jsx b/ui/e2e/fixtures/streaming-scroll.jsx index 67267e3d1..349f73d5c 100644 --- a/ui/e2e/fixtures/streaming-scroll.jsx +++ b/ui/e2e/fixtures/streaming-scroll.jsx @@ -21,7 +21,7 @@ function Fixture() { const follow = useScrollFollow({ containerRef: ref, enabled: true, scopeKey: sid, contentKey: messages.length > 0, contentSelector: '[data-chat-scroll-content]' }); useEffect(() => { store.setActiveSession(sid); - store.appendRealtimeBatch(sid, Array.from({ length: 24 }, (_, index) => [ + store.appendRealtimeBatch(sid, Array.from({ length: Number(new URLSearchParams(location.search).get('history') ?? 24) }, (_, index) => [ { ...base, id: `user-${index}`, kind: 'text', role: 'user', content: `Question ${index}` }, { ...base, id: `answer-${index}`, kind: 'text', role: 'assistant', content: `Answer ${index}: This is a historical response with enough text to read while a new response streams.\n\nSecond paragraph with a searchable needle ${index}.` }, ]).flat()); @@ -55,7 +55,7 @@ function Fixture() { return
follow.setPaused(true)} chatMessages={messages} visibleMessages={messages} visibleMessageCount={100} diff --git a/ui/e2e/streaming-lifecycle.spec.mjs b/ui/e2e/streaming-lifecycle.spec.mjs new file mode 100644 index 000000000..30ade1f25 --- /dev/null +++ b/ui/e2e/streaming-lifecycle.spec.mjs @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; + +const thoughts = (count) => Array.from({ length: count }, (_, i) => `Thought ${i}: compare the implementation and preserve the reading position.`).join('\n\n'); +const top = (locator) => locator.evaluate((node) => node.scrollTop); +const gap = (locator) => locator.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop); +const outer = (page) => page.locator('[data-chat-search-surface]'); +const childOuter = (page) => page.locator('[data-stream-scroll-viewport]').first(); +const latest = (page) => page.getByRole('button', { name: 'Back to latest' }); + +// Exercise native wheel events on real nested overflow elements, not mocked scroll metrics. +test('short thinking ignores upward wheels, then global resume restores the inner tail', async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-scroll.html?history=0'); + await page.evaluate(() => window.streamFixture.think('A short thought.')); + const inner = page.getByRole('region', { name: 'Live thinking content' }); + await expect(inner).toContainText('A short thought.'); + expect(await gap(outer(page))).toBe(0); + expect(await gap(inner)).toBe(0); + await inner.hover(); + await page.mouse.wheel(0, -30); + await expect(latest(page)).toHaveCount(0); + await page.evaluate((text) => window.streamFixture.think(text), thoughts(50)); + await expect(inner).toContainText('Thought 49'); + await expect.poll(() => gap(inner)).toBeLessThan(3); + await inner.hover(); + await page.mouse.wheel(0, -80); + await expect.poll(() => gap(inner)).toBeGreaterThan(40); + await expect(latest(page)).toBeVisible(); + expect(await gap(outer(page))).toBe(0); + const reading = await top(inner); + await page.evaluate((text) => window.streamFixture.think(text), thoughts(70)); + await expect(inner).toContainText('Thought 69'); + expect(Math.abs(await top(inner) - reading)).toBeLessThan(2); + await latest(page).click(); + await expect.poll(() => gap(inner)).toBeLessThan(3); + await page.evaluate((text) => window.streamFixture.think(text), thoughts(90)); + await expect(inner).toContainText('Thought 89'); + await expect.poll(() => gap(inner)).toBeLessThan(3); + await expect(latest(page)).toHaveCount(0); +}); + +test('upward wheels over short thinking yield to the scrollable conversation', async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-scroll.html'); + await page.evaluate(() => window.streamFixture.think('A short thought.')); + const inner = page.getByRole('region', { name: 'Live thinking content' }); + await expect(inner).toContainText('A short thought.'); + await expect.poll(() => gap(outer(page))).toBeLessThan(3); + await inner.hover(); + await page.mouse.wheel(0, -70); + await expect.poll(() => gap(outer(page))).toBeGreaterThan(30); + await expect(latest(page)).toBeVisible(); + const reading = await top(outer(page)); + await page.evaluate(() => window.streamFixture.think('A short thought. Now compare the result.')); + await expect(inner).toContainText('Now compare the result.'); + expect(Math.abs(await top(outer(page)) - reading)).toBeLessThan(2); +}); + +for (const cancel of [false, true]) { + test(`stream search waits for the visible match${cancel ? ' and respects cancellation' : ''}`, async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-lifecycle.html?transcript'); + await page.evaluate(() => window.streamLifecycle.set({ messages: [ + { id: 'question', type: 'user', content: 'Read a long response' }, + { id: 'answer', type: 'assistant', content: 'Starting.', isStreaming: true }, + ] })); + await outer(page).click({ position: { x: 20, y: 100 } }); + await page.keyboard.press('Meta+f'); + await page.getByRole('searchbox').fill('TAIL_NEEDLE'); + await page.evaluate(() => window.streamLifecycle.set({ messages: [ + { id: 'question', type: 'user', content: 'Read a long response' }, + { id: 'answer', type: 'assistant', content: 'A paragraph to read.\n\n'.repeat(240) + 'TAIL_NEEDLE', isStreaming: true }, + ] })); + await expect(page.locator('mark[aria-current="true"]')).toHaveCount(0); + if (cancel) { + await outer(page).hover({ position: { x: 20, y: 100 } }); + await page.mouse.wheel(0, -30); + } + await expect(page.locator('mark[aria-current="true"]')).toHaveText('TAIL_NEEDLE'); + if (cancel) expect(await top(outer(page))).toBe(0); + else { + await expect.poll(() => top(outer(page))).toBeGreaterThan(500); + await expect(page.locator('mark[aria-current="true"]')).toBeInViewport(); + } + }); +} + +test('historical sessions open at the bottom with auto follow disabled and preserve revisits', async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-lifecycle.html?auto=false'); + await expect(page.getByText('Session a message 239.', { exact: false })).toBeVisible(); + await expect.poll(() => gap(outer(page))).toBeLessThan(3); + const anchor = await outer(page).evaluate((node) => { + const edge = node.getBoundingClientRect().top; + const row = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > edge); + return { key: row.dataset.messageKey, offset: row.getBoundingClientRect().top - edge }; + }); + await page.evaluate(() => window.streamLifecycle.append(10)); + await expect.poll(() => gap(outer(page))).toBeGreaterThan(100); + await expect.poll(() => outer(page).evaluate((node, key) => { + const row = [...node.querySelectorAll('[data-message-key]')].find((item) => item.dataset.messageKey === key); + return row ? row.getBoundingClientRect().top - node.getBoundingClientRect().top : -100000; + }, anchor.key)).toBeCloseTo(anchor.offset, 0); + await outer(page).hover({ position: { x: 20, y: 300 } }); + await page.mouse.wheel(0, -150); + await expect(latest(page)).toBeVisible(); + const reading = await outer(page).evaluate((node) => { + const edge = node.getBoundingClientRect().top; + const row = [...node.querySelectorAll('[data-message-key]')].find((item) => item.getBoundingClientRect().bottom > edge); + return { key: row.dataset.messageKey, offset: row.getBoundingClientRect().top - edge }; + }); + await page.evaluate(() => window.streamLifecycle.setSession(1)); + await expect.poll(() => gap(outer(page))).toBeLessThan(3); + await page.evaluate(() => window.streamLifecycle.setSession(0)); + await expect.poll(() => outer(page).evaluate((node, key) => { + const row = [...node.querySelectorAll('[data-message-key]')].find((item) => item.dataset.messageKey === key); + return row ? row.getBoundingClientRect().top - node.getBoundingClientRect().top : -100000; + }, reading.key)).toBeCloseTo(reading.offset, 0); +}); + +for (const paused of [false, true]) { + test(`subagent drains completion text while ${paused ? 'preserving the reader' : 'following the bottom'}`, async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-lifecycle.html?child-direct'); + await page.evaluate(() => window.streamLifecycle.text('Initial answer.\n\n'.repeat(60))); + await expect.poll(() => gap(childOuter(page))).toBeLessThan(3); + await expect.poll(() => top(childOuter(page))).toBeGreaterThan(200); + if (paused) { + await childOuter(page).hover({ position: { x: 20, y: 200 } }); + await page.mouse.wheel(0, -100); + await expect(latest(page)).toBeVisible(); + } + const reading = await top(childOuter(page)); + await page.evaluate(() => { window.streamLifecycle.text('Remaining text.\n\n'.repeat(100) + 'DRAIN_FINISHED'); window.streamLifecycle.finish(); }); + await expect(page.getByText('DRAIN_FINISHED', { exact: false })).toBeVisible(); + if (paused) expect(Math.abs(await top(childOuter(page)) - reading)).toBeLessThan(2); + else await expect.poll(() => gap(childOuter(page))).toBeLessThan(3); + }); +} + +test('completion snapshot refresh preserves the mounted thinking viewport and its reading state', async ({ page }) => { + const thought = thoughts(60); + const base = { sessionId: 's::sub::child', provider: 'pilotdeck', timestamp: '2026-09-05T00:00:00Z', role: 'assistant' }; + const oldSnapshot = [{ ...base, id: 'old', kind: 'text', content: 'An older persisted step.' }]; + let finishFetch; + let requests = 0; + await page.route('**/api/sessions/s/subagent/child/messages', async (route) => { + requests += 1; + if (requests === 1) return route.fulfill({ json: { messages: oldSnapshot } }); + await new Promise((resolve) => { finishFetch = resolve; }); + await route.fulfill({ json: { messages: [...oldSnapshot, { ...base, id: 'snapshot-thought', kind: 'thinking', role: undefined, content: thought }] } }); + }); + await page.goto('/e2e/fixtures/streaming-lifecycle.html?child'); + await expect(page.getByText('An older persisted step.')).toBeVisible(); + await page.evaluate((text) => window.streamLifecycle.think(text), thought); + const inner = page.getByRole('region', { name: 'Live thinking content' }); + await expect(inner).toContainText('Thought 59'); + await expect.poll(() => gap(inner)).toBeLessThan(3); + await inner.hover(); + await page.mouse.wheel(0, -100); + await expect.poll(() => gap(inner)).toBeGreaterThan(50); + const reading = await top(inner); + await inner.evaluate((node) => { window.originalThinkingViewport = node; }); + await page.evaluate(() => window.streamLifecycle.finish()); + await expect.poll(() => requests).toBe(2); + await expect(inner).toBeVisible(); + expect(await inner.evaluate((node) => node === window.originalThinkingViewport)).toBe(true); + finishFetch(); + await expect.poll(() => page.evaluate(() => window.streamLifecycle.detail.isLoading)).toBe(false); + await expect(page.getByRole('button', { name: 'Thought process' })).toHaveAttribute('aria-expanded', 'true'); + expect(await inner.evaluate((node) => node === window.originalThinkingViewport)).toBe(true); + expect(Math.abs(await top(inner) - reading)).toBeLessThan(2); +}); diff --git a/ui/e2e/streaming-scroll.config.mjs b/ui/e2e/streaming-scroll.config.mjs index c69e88e6e..d2aa0b6fe 100644 --- a/ui/e2e/streaming-scroll.config.mjs +++ b/ui/e2e/streaming-scroll.config.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ testDir: '.', - testMatch: 'streaming-scroll.spec.mjs', + testMatch: ['streaming-scroll.spec.mjs', 'streaming-lifecycle.spec.mjs'], outputDir: '/tmp/pilotdeck-stream-playwright', workers: 1, use: { baseURL: 'http://127.0.0.1:5179', viewport: { width: 1100, height: 800 }, screenshot: 'only-on-failure' }, diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 4ca8fe865..2ed8171a6 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -195,7 +195,7 @@ function ChatInterfaceV2({ setCanAbortSession, isAborting: _isAborting, setIsAborting, - isUserScrolledUp, + canReturnToLatest, setIsUserScrolledUp, tokenBudget, setTokenBudget, @@ -858,7 +858,7 @@ function ChatInterfaceV2({
{ - if (beforeProcessAttachments.length === 0 && afterProcessAttachments.length === 0) { - return content; - } - + // Keep the body in the same React slot when completed process attachments + // appear, so thinking expansion and its nested scroll controller survive. return (
{beforeProcessAttachments.map(renderProcessAttachment)} - {content} + {content} {afterProcessAttachments.map(renderProcessAttachment)}
); diff --git a/ui/src/components/chat-v2/MessagesPaneV2.tsx b/ui/src/components/chat-v2/MessagesPaneV2.tsx index 6e5324575..b4fab4086 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.tsx @@ -43,7 +43,7 @@ type DiffLine = { type: string; content: string; lineNum: number }; type MessagesPaneV2Props = { scrollContainerRef: RefObject; - isScrollPaused?: boolean; + showReturnToLatest?: boolean; onResumeScroll?: () => void; onPauseScroll?: () => void; isLoadingSessionMessages: boolean; @@ -326,7 +326,7 @@ function isForkedChatSession(session: ProjectSession | null): boolean { function MessagesPaneV2({ scrollContainerRef, - isScrollPaused = false, + showReturnToLatest = false, onResumeScroll, onPauseScroll, isLoadingSessionMessages, @@ -1392,7 +1392,7 @@ function MessagesPaneV2({ /> ) : null}
- {isScrollPaused && onResumeScroll ? ( + {showReturnToLatest && onResumeScroll ? (
- {follow.isPaused ? ( + {follow.canReturnToLatest ? (