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.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..349f73d5c --- /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: 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()); + }, [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-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 new file mode 100644 index 000000000..ea0bd7347 --- /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', 'streaming-lifecycle.spec.mjs', 'streaming-search.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/e2e/streaming-search.spec.mjs b/ui/e2e/streaming-search.spec.mjs new file mode 100644 index 000000000..2c8c9e98c --- /dev/null +++ b/ui/e2e/streaming-search.spec.mjs @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +const conversation = (page) => page.locator('[data-chat-search-surface]'); +const top = (locator) => locator.evaluate((node) => node.scrollTop); +const pendingText = '[data-chat-search-render-pending="true"]'; +const history = (count) => Array.from({ length: count }, (_, index) => ({ + id: `history-${index}`, type: index % 2 ? 'assistant' : 'user', + content: `Historical message ${index}.\n\nAnother paragraph for reading.`, +})); +async function search(page, viewport, query) { + await viewport.click({ position: { x: 20, y: 100 } }); + await page.keyboard.press('Meta+f'); + await page.getByRole('searchbox').fill(query); +} + +for (const scenario of [ + { name: 'a phrase spanning text and a link', content: 'Visit https://example.com/unique-guide now.', query: 'Visit https://example.com/unique-guide', link: 'https://example.com/unique-guide', count: 30 }, + { name: 'a hidden Markdown URL', content: 'Read [Guide](https://example.com/unique-reference).', query: 'unique-reference', link: 'Guide', count: 30 }, + { name: 'a virtualized distant result without a highlight', content: 'Read [Guide](https://example.com/unique-reference).', query: 'unique-reference', link: 'Guide', count: 200 }, +]) { + test(`search locates ${scenario.name}`, async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-lifecycle.html?transcript'); + await page.evaluate(({ messages, content }) => window.streamLifecycle.set({ working: false, messages: [ + ...messages, { id: 'target', type: 'assistant', content }, + ] }), { messages: history(scenario.count), content: scenario.content }); + await search(page, conversation(page), scenario.query); + await expect(page.getByRole('link', { name: scenario.link, exact: true })).toBeInViewport(); + await expect(page.locator('mark[aria-current="true"]')).toHaveCount(0); + await conversation(page).hover({ position: { x: 20, y: 100 } }); + await page.mouse.wheel(0, -160); + await expect.poll(() => conversation(page).evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop)).toBeGreaterThan(50); + const reading = await top(conversation(page)); + await page.evaluate(() => window.streamLifecycle.set({ messages: [ + ...window.streamLifecycle.messages, { id: 'later', type: 'assistant', content: 'Another response.\n\n'.repeat(20) }, + ] })); + await page.waitForTimeout(250); + expect(Math.abs(await top(conversation(page)) - reading)).toBeLessThan(2); + }); +} + +for (const scenario of [ + { name: 'while the stream remains open', streaming: true, cancel: false }, + { name: 'after the backend completes', streaming: false, cancel: false }, + { name: 'with user cancellation', streaming: false, cancel: true }, + { name: 'before its first animation frame', streaming: false, cancel: false, empty: true }, +]) { + test(`search fallback waits for the rendered tail ${scenario.name}`, async ({ page }) => { + // Advance animation frames explicitly so completion cannot race the assertion. + await page.clock.install({ time: new Date('2026-09-05T00:00:00Z') }); + await page.goto('/e2e/fixtures/streaming-lifecycle.html?transcript'); + await page.evaluate((empty) => window.streamLifecycle.set({ messages: [ + { id: 'question', type: 'user', content: 'Read a long response' }, + { id: 'answer', type: 'assistant', content: empty ? '' : 'Starting.', isStreaming: true }, + ] }), Boolean(scenario.empty)); + await search(page, conversation(page), 'unique-reference'); + await page.clock.pauseAt(new Date('2026-09-05T00:01:00Z')); + await page.evaluate((streaming) => window.streamLifecycle.set({ working: streaming, messages: [ + { id: 'question', type: 'user', content: 'Read a long response' }, + { id: 'answer', type: 'assistant', content: 'A paragraph to read.\n\n'.repeat(200) + '[Guide](https://example.com/unique-reference)', isStreaming: streaming }, + ] }), scenario.streaming); + await expect(conversation(page).locator(pendingText)).toHaveCount(1); + await page.clock.runFor(32); + expect(await top(conversation(page))).toBe(0); + await expect(conversation(page).locator(pendingText)).toHaveCount(1); + if (scenario.cancel) { + await conversation(page).dispatchEvent('wheel', { deltaY: -30 }); + } + await page.clock.runFor(2500); + await expect(conversation(page).locator(pendingText)).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Guide', exact: true })).toHaveCount(1); + await expect(page.locator('mark[aria-current="true"]')).toHaveCount(0); + if (scenario.cancel) expect(await top(conversation(page))).toBe(0); + else await expect.poll(() => top(conversation(page))).toBeGreaterThan(500); + }); +} + +test('subagent search retains message-level fallback for hidden link URLs', async ({ page }) => { + await page.goto('/e2e/fixtures/streaming-lifecycle.html?child-direct'); + await page.evaluate(() => { + window.streamLifecycle.text('Read this response.\n\n'.repeat(50) + '[Guide](https://example.com/unique-reference)'); + window.streamLifecycle.finish(); + }); + const viewport = page.locator('[data-stream-scroll-viewport]').first(); + await expect(page.getByRole('link', { name: 'Guide', exact: true })).toHaveCount(1); + await viewport.evaluate((node) => { node.scrollTop = 0; node.dispatchEvent(new Event('scroll')); }); + await search(page, viewport, 'unique-reference'); + await expect.poll(() => top(viewport)).toBeGreaterThan(200); + await expect(page.locator('mark[aria-current="true"]')).toHaveCount(0); +}); diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index eee5f14ed..2ed8171a6 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, + canReturnToLatest, 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 : []), @@ -276,14 +276,12 @@ function MessageRowV2({ ); const withProcessRows = (content: ReactNode) => { - 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)}
); @@ -542,57 +540,20 @@ 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} -
-
-
, + , ); } // Assistant: plain prose, no avatar and no bubble. const hasAssistantProse = contentDisplayText.trim().length > 0; + const isTextRenderingPending = contentDisplayText !== formattedContent; const showStreamingCursor = Boolean(message.isStreaming && !contentDisplayText); const resolvedShowAssistantActions = showAssistantActions ?? true; const assistantMessageTime = resolvedShowAssistantActions @@ -606,8 +567,11 @@ function MessageRowV2({ const assistantForkDisabled = Boolean( forkDisabled || isSessionRunning || message.isStreaming || !message.entryId, ); - const assistantBody = (hasAssistantProse || showStreamingCursor || assistantArtifacts.length > 0) ? ( -
+ const assistantBody = (hasAssistantProse || showStreamingCursor || isTextRenderingPending || assistantArtifacts.length > 0) ? ( +
{showStreamingCursor ? ( ) : ( @@ -618,7 +582,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..b4fab4086 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; + showReturnToLatest?: 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, + showReturnToLatest = 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}
+ {showReturnToLatest && 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..d4b4b40c5 --- /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); + const { hasOverflow } = 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..667a00878 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: true, 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.canReturnToLatest ? ( + + ) : null}
); } diff --git a/ui/src/components/chat-v2/SubagentDetailModal.tsx b/ui/src/components/chat-v2/SubagentDetailModal.tsx index fd96185db..7bcbbd1fe 100644 --- a/ui/src/components/chat-v2/SubagentDetailModal.tsx +++ b/ui/src/components/chat-v2/SubagentDetailModal.tsx @@ -51,13 +51,13 @@ export default function SubagentDetailModal({ }; let content: ReactNode; - if (isLoading) { + if (isLoading && messages.length === 0) { content = (
); - } else if (error) { + } else if (error && messages.length === 0) { content = (
{t('subagent.loadError', { error })} @@ -72,6 +72,7 @@ export default function SubagentDetailModal({ } else { content = ( 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..a408de91f 100644 --- a/ui/src/components/chat-v2/processGrouping.test.ts +++ b/ui/src/components/chat-v2/processGrouping.test.ts @@ -279,6 +279,17 @@ describe('processGrouping', () => { expect(processAttachments(thirdAssistant)).toHaveLength(0); }); + it('keeps alternating thinking and tool segments in chronological order after completion', () => { + const transcript = [user('u'), thinking('think-a'), tool('read', 'Read'), thinking('think-b'), tool('bash', 'Bash'), assistant('answer', 'Done')]; + const items = buildRenderableMessageItems(transcript, { isAssistantWorking: false }); + const timeline = items.flatMap((item) => [ + ...item.beforeProcessAttachments.flatMap((attachment) => attachment.processDetailMessages.map((message) => message.id)), + item.message.id, + ...item.afterProcessAttachments.flatMap((attachment) => attachment.processDetailMessages.map((message) => message.id)), + ]); + expect(timeline).toEqual(transcript.map((message) => message.id)); + }); + it('attaches completed run duration after the user turn finishes', () => { const messages: ChatMessage[] = [ user('u1'), @@ -497,7 +508,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 +520,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..0b31ffe1c 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', ); } @@ -380,7 +379,6 @@ function canHostProcessSummary(message: ChatMessage): boolean { !message.isInteractivePrompt && !message.isSubagentContainer && !message.isTaskNotification && - !message.isThinking && typeof message.content === 'string' && message.content.trim().length > 0 ); @@ -979,12 +977,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 +1010,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 +1135,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 +1184,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 +1198,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.render.test.tsx b/ui/src/components/chat-v2/useChatHistorySearch.render.test.tsx new file mode 100644 index 000000000..db508032d --- /dev/null +++ b/ui/src/components/chat-v2/useChatHistorySearch.render.test.tsx @@ -0,0 +1,64 @@ +// @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 { useChatHistorySearch } from './useChatHistorySearch'; +import type { SearchableChatMessageInput } from './chatHistorySearchUtils'; + +vi.mock('../../contexts/FindShortcutContext', () => ({ useRegisterFindShortcutTarget: () => {} })); +const messages: SearchableChatMessageInput[] = [{ + messageKey: 'target', message: { id: 'target', type: 'assistant', timestamp: '2026-09-05T00:00:00Z', content: '[Guide](https://example.com/unique-reference)', isStreaming: false }, +}]; +const heights = [100]; +const loadAllMessages = () => {}; +let frames: Map; +let frameId: number; +let search: ReturnType; +function Harness({ pending }: { pending: boolean }) { + const ref = useRef(null); + search = useChatHistorySearch({ + scrollContainerRef: ref, keyedMessages: messages, measuredItemHeights: heights, + allMessagesLoaded: true, hasMoreMessages: false, loadAllMessages, sessionId: 'session', + }); + return
+
+ +
+
; +} +async function flushFrame() { + await act(async () => {}); + await act(async () => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(performance.now())); + }); +} +beforeEach(() => { + frames = new Map(); frameId = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('search fallback rendering readiness', () => { + it.each([false, true])('reacts to completion with no text-node change (cancelled: %s)', async (cancelled) => { + const { getByTestId, rerender } = render(); + const viewport = getByTestId('viewport'); + const scrollTo = vi.fn(); + viewport.scrollTo = scrollTo; + act(() => { search.openSearch(); search.setQuery('unique-reference'); }); + await flushFrame(); + // Backend completion alone is insufficient while displayed text is pending. + expect(scrollTo).not.toHaveBeenCalled(); + if (cancelled) fireEvent.wheel(viewport, { deltaY: -30 }); + rerender(); + await flushFrame(); + expect(viewport.querySelector('mark')).toBeNull(); + expect(scrollTo).toHaveBeenCalledTimes(cancelled ? 0 : 1); + // Later DOM updates must not restart successful or cancelled navigation. + act(() => { viewport.querySelector('a')!.textContent = 'Updated guide label'; }); + await flushFrame(); + expect(scrollTo).toHaveBeenCalledTimes(cancelled ? 0 : 1); + }); +}); diff --git a/ui/src/components/chat-v2/useChatHistorySearch.ts b/ui/src/components/chat-v2/useChatHistorySearch.ts index 3f01e51dc..baefd2a81 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,16 @@ 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 requestedMatchRef = useRef(null); + const pendingRevealRef = useRef<{ match: ChatHistorySearchMatch; navigation: number; ready: boolean; coarseJumped: boolean } | null>(null); + const refreshHighlightsRef = useRef<() => void>(() => {}); const searchableMessages = useMemo( () => buildSearchableMessages(keyedMessages), @@ -53,6 +59,9 @@ export function useChatHistorySearch({ const activeMatch: ChatHistorySearchMatch | null = matches[activeMatchIndex] ?? null; const closeSearch = useCallback(() => { + navigationRef.current += 1; + requestedMatchRef.current = null; + pendingRevealRef.current = null; setIsOpen(false); setQuery(''); setActiveMatchIndex(0); @@ -70,8 +79,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) => { @@ -87,41 +95,19 @@ export function useChatHistorySearch({ }, [matches, query, scrollContainerRef, searchableMessages]); const revealMatch = useCallback(async (match: ChatHistorySearchMatch) => { - await ensureAllMessagesLoaded(); - - const container = scrollContainerRef.current; - if (!container) return; - - const revealRenderedMatch = (behavior: ScrollBehavior): boolean => { - const target = applySearchHighlights(match); - if (!target) return false; - scrollSearchTargetIntoView(container, target, behavior); - return true; - }; - - // Nearby results are normally still mounted by the virtualized list. In - // that case, move directly from the current viewport instead of first - // resetting scrollTop from the beginning of the conversation. - if (revealRenderedMatch('smooth')) return; - - // 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); - - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - - revealRenderedMatch('auto'); - }, [ - applySearchHighlights, - ensureAllMessagesLoaded, - measuredItemHeights, - scrollContainerRef, - ]); + const navigation = ++navigationRef.current; + const pending = { match, navigation, ready: false, coarseJumped: false }; + pendingRevealRef.current = pending; + onNavigate?.(); + try { + await ensureAllMessagesLoaded(); + } catch { + // Results already loaded remain searchable if fetching older history fails. + } + if (navigation !== navigationRef.current) return; + pending.ready = true; + refreshHighlightsRef.current(); + }, [ensureAllMessagesLoaded, onNavigate]); const goToMatch = useCallback((index: number) => { if (matches.length === 0) return; @@ -165,18 +151,63 @@ export function useChatHistorySearch({ const container = scrollContainerRef.current; if (!isOpen || !activeMatch || !query.trim()) { if (container) clearSearchHighlights(container); + requestedMatchRef.current = null; + pendingRevealRef.current = null; return; } + const key = `${sessionId}:${query}:${activeMatch.messageKey}:${activeMatch.offset}`; + if (requestedMatchRef.current === key) return; + requestedMatchRef.current = key; void revealMatch(activeMatch); - }, [activeMatch, isOpen, query, revealMatch, scrollContainerRef]); + }, [activeMatch, isOpen, query, revealMatch, scrollContainerRef, sessionId]); + // The search index sees complete text before the typewriter exposes it. Wait + // for a mark or for the row to finish rendering: cross-node phrases and hidden + // link URLs may never produce a mark. Disconnect while marking our DOM changes. useEffect(() => { - if (!isOpen || !query.trim()) return undefined; - const frame = requestAnimationFrame(() => { - applySearchHighlights(activeMatch); + const container = scrollContainerRef.current; + if (!container || !isOpen || !query.trim()) return; + let frame: number | null = null; + const observer = new MutationObserver(() => schedule()); + const observe = () => observer.observe(container, { + childList: true, characterData: true, subtree: true, + attributes: true, attributeFilter: ['data-chat-search-render-pending'], }); - return () => cancelAnimationFrame(frame); - }, [activeMatch, applySearchHighlights, isOpen, query, renderWindowKey]); + const refresh = () => { + frame = null; + observer.disconnect(); + const target = applySearchHighlights(activeMatch); + observe(); + const pending = pendingRevealRef.current; + if (!pending?.ready || pending.navigation !== navigationRef.current) return; + const canReveal = target && (target.matches('mark[aria-current="true"]') + || !target.querySelector('[data-chat-search-render-pending="true"]')); + if (canReveal) { + scrollSearchTargetIntoView(container, target, pending.coarseJumped ? 'auto' : 'smooth'); + onNavigate?.(); + pendingRevealRef.current = null; + } else if (!target && !pending.coarseJumped) { + // Only a missing row needs a virtualization jump. A mounted row may + // still be draining text; keep waiting without moving the reader again. + pending.coarseJumped = true; + const match = matches.find((candidate) => candidate.messageKey === pending.match.messageKey + && candidate.offset === pending.match.offset) ?? pending.match; + scrollToMessageIndex(container, measuredItemHeights, match.messageIndex); + schedule(); + } + }; + const schedule = () => { + if (frame === null) frame = requestAnimationFrame(refresh); + }; + refreshHighlightsRef.current = schedule; + observe(); + schedule(); + return () => { + observer.disconnect(); + if (frame !== null) cancelAnimationFrame(frame); + refreshHighlightsRef.current = () => {}; + }; + }, [activeMatch, applySearchHighlights, isOpen, query, renderWindowKey, matches, measuredItemHeights, onNavigate, scrollContainerRef]); useEffect(() => { if (matches.length === 0) { @@ -192,7 +223,26 @@ export function useChatHistorySearch({ if (!isOpen) return; const container = scrollContainerRef.current; if (!container) return; - return () => clearSearchHighlights(container); + const cancelNavigation = () => { + navigationRef.current += 1; + pendingRevealRef.current = null; + }; + const cancelKeyboardNavigation = (event: KeyboardEvent) => { + if (event.target instanceof Element && event.target.closest('input, textarea, [contenteditable="true"]')) return; + 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', cancelKeyboardNavigation); + container.addEventListener('pointerdown', cancelNavigation); + return () => { + navigationRef.current += 1; + container.removeEventListener('wheel', cancelNavigation); + container.removeEventListener('touchmove', cancelNavigation); + container.removeEventListener('keydown', cancelKeyboardNavigation); + container.removeEventListener('pointerdown', cancelNavigation); + clearSearchHighlights(container); + }; }, [isOpen, scrollContainerRef]); return { diff --git a/ui/src/components/chat-v2/useSubagentMessages.refresh.test.tsx b/ui/src/components/chat-v2/useSubagentMessages.refresh.test.tsx new file mode 100644 index 000000000..29b09e1bc --- /dev/null +++ b/ui/src/components/chat-v2/useSubagentMessages.refresh.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { NormalizedMessage, SessionStore } from '../../stores/useSessionStore'; +import { useSubagentMessages } from './useSubagentMessages'; +import { authenticatedFetch } from '../../utils/api'; + +vi.mock('../../utils/api', () => ({ authenticatedFetch: vi.fn() })); +afterEach(() => { cleanup(); vi.resetAllMocks(); }); +const message = (id: string, content: string, overrides: Partial = {}): NormalizedMessage => ({ + id, sessionId: 'session', kind: 'thinking', content, provider: 'pilotdeck', timestamp: '2026-09-05T00:00:00Z', ...overrides, +}); +function deferred() { + let resolve!: (value: Response) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve: (messages: NormalizedMessage[]) => resolve({ ok: true, json: async () => ({ messages }) } as Response) }; +} + +describe('subagent refresh lifecycle', () => { + it('retains the live tail while completion refresh is pending and bridges snapshot identity', async () => { + const initial = deferred(); const final = deferred(); + vi.mocked(authenticatedFetch).mockReturnValueOnce(initial.promise).mockReturnValueOnce(final.promise); + const realtime = [message('local-final', 'New thought', { renderKey: 'reader', timestamp: '2026-09-05T00:00:01Z' })]; + const store = { getSubagentDetailMessages: () => realtime } as unknown as SessionStore; + const { result, rerender } = renderHook(({ status }) => useSubagentMessages('s', 'child', undefined, store, status), { initialProps: { status: 'running' } }); + await act(async () => initial.resolve([message('old', 'Old thought')])); + expect(result.current.messages.map((row) => row.content)).toEqual(['Old thought', 'New thought']); + rerender({ status: 'completed' }); + expect(result.current.isLoading).toBe(true); + expect(result.current.messages.map((row) => row.content)).toEqual(['Old thought', 'New thought']); + await act(async () => final.resolve([message('old', 'Old thought'), message('snapshot-final', 'New thought')])); + expect(result.current.messages[1].renderKey).toBe('reader'); + expect(result.current.isLoading).toBe(false); + }); + + it('isolates child scopes and ignores an aborted refresh that resolves late', async () => { + const initial = deferred(); const stale = deferred(); const current = deferred(); + vi.mocked(authenticatedFetch).mockReturnValueOnce(initial.promise).mockReturnValueOnce(stale.promise).mockReturnValueOnce(current.promise); + const store = { getSubagentDetailMessages: (_session: string, child: string) => child === 'one' + ? [message('live-one', 'Same thought', { renderKey: 'one-reader' })] : [] } as unknown as SessionStore; + const { result, rerender } = renderHook(({ child, status }) => useSubagentMessages('s', child, undefined, store, status), { + initialProps: { child: 'one', status: 'running' }, + }); + await act(async () => initial.resolve([])); + rerender({ child: 'one', status: 'completed' }); + rerender({ child: 'two', status: 'completed' }); + expect(result.current.messages).toEqual([]); + await act(async () => current.resolve([message('two-snapshot', 'Same thought')])); + await act(async () => stale.resolve([message('one-snapshot', 'Same thought')])); + expect(result.current.messages[0].id).toBe('two-snapshot'); + expect(result.current.messages[0].renderKey).not.toBe('one-reader'); + }); + + it('keeps displayed content if a background refresh fails', async () => { + vi.mocked(authenticatedFetch).mockResolvedValueOnce({ ok: true, json: async () => ({ messages: [message('old', 'Displayed thought')] }) } as Response) + .mockRejectedValueOnce(new Error('Network reset')); + const { result, rerender } = renderHook(({ status }) => useSubagentMessages('s', 'child', undefined, undefined, status), { initialProps: { status: 'running' } }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + rerender({ status: 'completed' }); + await waitFor(() => expect(result.current.error).toBe('Network reset')); + expect(result.current.messages[0].content).toBe('Displayed thought'); + }); +}); diff --git a/ui/src/components/chat-v2/useSubagentMessages.test.ts b/ui/src/components/chat-v2/useSubagentMessages.test.ts index 3318f8ea2..60d447a87 100644 --- a/ui/src/components/chat-v2/useSubagentMessages.test.ts +++ b/ui/src/components/chat-v2/useSubagentMessages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { NormalizedMessage } from '../../stores/useSessionStore'; import type { SessionProvider } from '../../types/app'; -import { mergeSubagentDetailMessages } from './useSubagentMessages'; +import { inheritSubagentRenderKeys, mergeSubagentDetailMessages } from './useSubagentMessages'; const PROVIDER = 'pilotdeck' as SessionProvider; @@ -108,3 +108,25 @@ describe('mergeSubagentDetailMessages', () => { ]); }); }); + + +describe('subagent snapshot rendering identity', () => { + it('preserves stream identity when snapshots omit the thinking role and change message IDs', () => { + const previous = [thinkingMessage('live', 'A thought', '2026-05-28T00:00:01Z', { renderKey: 'reading' })]; + const snapshot = [thinkingMessage('persisted', 'A thought', '2026-05-28T00:00:02Z', { role: undefined })]; + expect(inheritSubagentRenderKeys(previous, snapshot)[0].renderKey).toBe('reading'); + }); + + it('assigns repeated identical thoughts one to one in transcript order', () => { + const previous = ['first', 'second'].map((id) => thinkingMessage(id, 'Again', '2026-05-28T00:00:01Z', { renderKey: id })); + const snapshot = ['persisted-a', 'persisted-b'].map((id) => thinkingMessage(id, 'Again', '2026-05-28T00:00:02Z')); + expect(inheritSubagentRenderKeys(previous, snapshot).map((message) => message.renderKey)).toEqual(['first', 'second']); + expect(inheritSubagentRenderKeys(previous, snapshot.slice(0, 1))[0].renderKey).toBe('persisted-a'); + }); + + it('keeps tool invocation identity when its result/content changes', () => { + const previous = [textMessage('live-tool', '', '2026-05-28T00:00:01Z', { kind: 'tool_use', toolId: 'call', renderKey: 'tool-row' })]; + const snapshot = [textMessage('persisted-tool', 'Completed', '2026-05-28T00:00:02Z', { kind: 'tool_use', toolId: 'call' })]; + expect(inheritSubagentRenderKeys(previous, snapshot)[0].renderKey).toBe('tool-row'); + }); +}); diff --git a/ui/src/components/chat-v2/useSubagentMessages.ts b/ui/src/components/chat-v2/useSubagentMessages.ts index a4e38553a..993bb5dd4 100644 --- a/ui/src/components/chat-v2/useSubagentMessages.ts +++ b/ui/src/components/chat-v2/useSubagentMessages.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; import type { ChatMessage } from '../chat/types/types'; import { normalizedToChatMessages } from '../chat/hooks/useChatMessages'; import type { NormalizedMessage, SessionStore } from '../../stores/useSessionStore'; @@ -88,6 +88,35 @@ export function mergeSubagentDetailMessages( return merged; } +// Snapshot IDs differ from stream IDs. Within one child transcript, preserve +// identity by ID/tool ID first, then by unambiguous content and occurrence order. +export function inheritSubagentRenderKeys(previous: NormalizedMessage[], next: NormalizedMessage[]): NormalizedMessage[] { + const kind = (message: NormalizedMessage) => message.kind === 'stream_delta' ? 'text' : message.kind; + const role = (message: NormalizedMessage) => kind(message) === 'text' ? message.role || 'assistant' : null; + const compatible = (a: NormalizedMessage, b: NormalizedMessage) => kind(a) === kind(b) && role(a) === role(b); + const signature = (message: NormalizedMessage) => JSON.stringify([kind(message), role(message), message.content]); + const key = (candidate: NormalizedMessage) => candidate.renderKey || candidate.id; + const exactMatches = next.map((message) => previous.find((candidate) => compatible(candidate, message) + && (candidate.id === message.id || (message.toolId && candidate.toolId === message.toolId)))); + // Reserve strong identities before matching by content, regardless of order. + const used = new Set(exactMatches.flatMap((match) => match ? [key(match)] : [])); + return next.map((message, index) => { + const candidates = previous.filter((candidate) => compatible(candidate, message) && !used.has(key(candidate))); + let match = exactMatches[index]; + if (!match && message.content) { + const sameContent = (candidate: NormalizedMessage) => signature(candidate) === signature(message); + // Different occurrence counts are ambiguous; do not transfer one row's + // expansion/reading state to a different repeated thought or answer. + if (previous.filter(sameContent).length === next.filter(sameContent).length) { + match = candidates.find(sameContent); + } + } + const renderKey = match ? key(match) : message.renderKey || message.id; + used.add(renderKey); + return renderKey === message.renderKey ? message : { ...message, renderKey }; + }); +} + export function useSubagentMessages( sessionId: string | null, subagentId: string | null, @@ -96,27 +125,34 @@ export function useSubagentMessages( refreshKey?: string, sessionRequestParams: SessionRequestParams = {}, ): SubagentMessagesResult { - const [snapshotMessages, setSnapshotMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const scope = JSON.stringify([sessionId, subagentId, projectPath, sessionRequestParams.sessionKind, + sessionRequestParams.parentSessionId, sessionRequestParams.relativeTranscriptPath]); + const [snapshot, setSnapshot] = useState<{ scope: string; refreshKey?: string; messages: NormalizedMessage[] } | null>(null); + const [request, setRequest] = useState<{ scope: string; isLoading: boolean; error: string | null } | null>(null); + const previousRef = useRef<{ scope: string; messages: NormalizedMessage[] } | null>(null); const abortRef = useRef(null); const { sessionKind, parentSessionId, relativeTranscriptPath } = sessionRequestParams; const realtimeMessages = sessionId && subagentId ? sessionStore?.getSubagentDetailMessages?.(sessionId, subagentId) ?? EMPTY_NORMALIZED_MESSAGES : EMPTY_NORMALIZED_MESSAGES; - const useSnapshotOnly = refreshKey === 'completed' || refreshKey === 'failed'; - const messages = useMemo(() => { - const normalized = mergeSubagentDetailMessages(snapshotMessages, realtimeMessages, useSnapshotOnly); - return normalizeSubagentDetailContainers( - filterSubagentDetailMessages(normalizedToChatMessages(normalized)), - ); - }, [snapshotMessages, realtimeMessages, useSnapshotOnly]); + const normalized = useMemo(() => { + const snapshotMessages = snapshot?.scope === scope ? snapshot.messages : EMPTY_NORMALIZED_MESSAGES; + // A status change starts a refresh. Until THAT snapshot arrives, retain the + // live tail instead of switching back to an older incomplete snapshot. + const useSnapshotOnly = snapshot?.scope === scope && snapshot.refreshKey === refreshKey + && (refreshKey === 'completed' || refreshKey === 'failed'); + const merged = mergeSubagentDetailMessages(snapshotMessages, realtimeMessages, useSnapshotOnly); + return inheritSubagentRenderKeys(previousRef.current?.scope === scope ? previousRef.current.messages : [], merged); + }, [snapshot, realtimeMessages, scope, refreshKey]); + useLayoutEffect(() => { previousRef.current = { scope, messages: normalized }; }, [scope, normalized]); + const messages = useMemo(() => normalizeSubagentDetailContainers( + filterSubagentDetailMessages(normalizedToChatMessages(normalized)), + ), [normalized]); useEffect(() => { if (!sessionId || !subagentId) { - setSnapshotMessages([]); - setIsLoading(false); - setError(null); + setSnapshot(null); + setRequest(null); return; } @@ -124,8 +160,7 @@ export function useSubagentMessages( const controller = new AbortController(); abortRef.current = controller; - setIsLoading(true); - setError(null); + setRequest({ scope, isLoading: true, error: null }); const params = new URLSearchParams(); if (projectPath) params.set('projectPath', projectPath); @@ -145,17 +180,16 @@ export function useSubagentMessages( .then((data) => { if (controller.signal.aborted) return; const normalized = Array.isArray(data.messages) ? data.messages : []; - setSnapshotMessages(normalized); - setIsLoading(false); + setSnapshot({ scope, refreshKey, messages: normalized }); + setRequest({ scope, isLoading: false, error: null }); }) .catch((err) => { if (controller.signal.aborted) return; - setError(err instanceof Error ? err.message : String(err)); - setIsLoading(false); + setRequest({ scope, isLoading: false, error: err instanceof Error ? err.message : String(err) }); }); return () => controller.abort(); - }, [sessionId, subagentId, projectPath, refreshKey, sessionKind, parentSessionId, relativeTranscriptPath]); + }, [sessionId, subagentId, projectPath, refreshKey, sessionKind, parentSessionId, relativeTranscriptPath, scope]); - return { messages, isLoading, error }; + return { messages, isLoading: request?.scope === scope && request.isLoading, error: request?.scope === scope ? request.error : null }; } 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..4f41cc93e 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, type ReadingAnchor } 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,8 @@ function hasEquivalentUserMessage(messages: ChatMessage[], pendingUserMessage: C type ConversationScrollPosition = { top: number; distanceFromBottom: number; + following?: boolean; + anchor?: ReadingAnchor | null; }; const CONVERSATION_SCROLL_BOTTOM_THRESHOLD = 40; @@ -281,7 +279,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 +319,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 +336,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 +346,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 +521,28 @@ 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, + canReturnToLatest, + setPaused: setIsUserScrolledUp, + getIsPaused, + pause: pauseScrollFollowing, + scrollToBottom, + scheduleFollow: scheduleScrollToBottom, + scheduleInitialPosition, + captureAnchor: captureReadingAnchor, + getReadingAnchor, + restoreReadingAnchor, + } = 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 +567,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 +578,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 +588,8 @@ export function useChatSessionState({ } }, [ + activeScrollKey, + captureReadingAnchor, hasMoreMessages, isLoadingMoreMessages, selectedProject, @@ -637,6 +606,8 @@ export function useChatSessionState({ if (activeScrollKey) { conversationScrollPositionsRef.current.set(activeScrollKey, { top: container.scrollTop, + following: Boolean(autoScrollToBottom) && !getIsPaused(), + anchor: getReadingAnchor(), distanceFromBottom: Math.max( 0, container.scrollHeight - container.scrollTop - container.clientHeight, @@ -644,9 +615,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 +625,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, autoScrollToBottom, getIsPaused, getReadingAnchor, loadOlderMessages]); // Reset scroll/pagination state on session change useLayoutEffect(() => { @@ -681,12 +640,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 +664,32 @@ export function useChatSessionState({ container.scrollHeight, container.clientHeight, ); + const wasFollowing = pendingRestore.position.following + ?? pendingRestore.position.distanceFromBottom <= CONVERSATION_SCROLL_BOTTOM_THRESHOLD; + if (!wasFollowing && pendingRestore.position.anchor) restoreReadingAnchor(pendingRestore.position.anchor); + else captureReadingAnchor(); pendingConversationScrollRestoreRef.current = null; pendingInitialScrollRef.current = false; - }, [activeScrollKey, chatMessages.length, isLoadingSessionMessages]); + }, [activeScrollKey, chatMessages.length, isLoadingSessionMessages, captureReadingAnchor, restoreReadingAnchor]); + + 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: Boolean(autoScrollToBottom) && !isUserScrolledUp, + anchor: getReadingAnchor(), + }); + }, [activeScrollKey, autoScrollToBottom, isUserScrolledUp, getReadingAnchor]); // 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) scheduleInitialPosition(); + }, [chatMessages.length, isLoadingSessionMessages, scheduleInitialPosition]); // Main session loading effect — store-based useEffect(() => { @@ -910,7 +883,7 @@ export function useChatSessionState({ }); if (autoScrollToBottom && isNearBottom()) { - setTimeout(() => scrollToBottom(), 200); + scheduleScrollToBottom(); } } } catch (error) { @@ -923,7 +896,7 @@ export function useChatSessionState({ autoScrollToBottom, externalMessageUpdate, isNearBottom, - scrollToBottom, + scheduleScrollToBottom, selectedProject, selectedSession, sessionRequestParams, @@ -950,11 +923,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 +960,7 @@ export function useChatSessionState({ limit: null, offset: 0, }); + if (cancelled) return; if (slot) { setHasMoreMessages(false); setTotalMessages(slot.total); @@ -982,9 +975,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 +1014,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 +1063,24 @@ 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, - ]); + // Keep each conversation's reading window, including pages exposed while + // follow is disabled, so revisiting it can restore the same message/offset. + const visibleWindowStartsRef = useRef(new Map()); + const visibleWindowScopeRef = useRef(activeScrollKey); + const changedWindowScope = visibleWindowScopeRef.current !== activeScrollKey; + visibleWindowScopeRef.current = activeScrollKey; + const preserveVisibleWindow = isUserScrolledUp || !autoScrollToBottom + || (changedWindowScope && activeScrollKey != null + && conversationScrollPositionsRef.current.get(activeScrollKey)?.following === false); + const defaultStart = Math.max(0, chatMessages.length - visibleMessageCount); + const previousStart = visibleWindowStartsRef.current.get(activeScrollKey); + const windowStart = preserveVisibleWindow && previousStart != null + ? Math.min(previousStart, defaultStart) : defaultStart; + if (chatMessages.length > 0) visibleWindowStartsRef.current.set(activeScrollKey, windowStart); + const effectiveVisibleCount = preserveVisibleWindow + ? Math.max(visibleMessageCount, chatMessages.length - windowStart) + : visibleMessageCount; + const visibleMessages = useMemo(() => selectVisibleMessages(chatMessages, effectiveVisibleCount), [chatMessages, effectiveVisibleCount]); useEffect(() => { const container = scrollContainerRef.current; @@ -1187,9 +1169,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 +1182,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 +1210,16 @@ export function useChatSessionState({ selectedSession, selectedProject, isLoadingAllMessages, - currentSessionId, + activeScrollKey, + captureReadingAnchor, sessionRequestParams, sessionStore, ]); const loadEarlierMessages = useCallback(() => { + pauseScrollFollowing(); setVisibleMessageCount((prev) => prev + 100); - }, []); + }, [pauseScrollFollowing]); return { chatMessages, @@ -1266,6 +1245,7 @@ export function useChatSessionState({ isAborting, setIsAborting, isUserScrolledUp, + canReturnToLatest, setIsUserScrolledUp, tokenBudget, setTokenBudget, @@ -1285,6 +1265,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..be5146c92 --- /dev/null +++ b/ui/src/components/chat/hooks/useScrollFollow.test.tsx @@ -0,0 +1,210 @@ +// @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.each(['scroll-first', 'resize-first'])('preserves user displacement when layout grows in the same frame (%s)', (order) => { + const { node, metrics } = setup(); + const row = node.querySelector('[data-message-key="reading"]')!; + row.getBoundingClientRect = () => ({ top: 750 - metrics.top, bottom: 1000 - metrics.top }) as DOMRect; + fireEvent.wheel(node, { deltaY: -30 }); + node.scrollTop = 770; + metrics.height += 50; + if (order === 'resize-first') resize(); + fireEvent.scroll(node); + resize(); flush(); + expect(node.scrollTop).toBe(770); + expect(api.isPaused).toBe(true); + }); + + it('combines an upward gesture with newly inserted history in the same frame', () => { + const { node, metrics } = setup(); + let inserted = 0; + const row = node.querySelector('[data-message-key="reading"]')!; + row.getBoundingClientRect = () => ({ top: 750 + inserted - metrics.top, bottom: 1000 + inserted - metrics.top }) as DOMRect; + fireEvent.wheel(node, { deltaY: -30 }); + node.scrollTop = 770; inserted = 100; metrics.height += 150; + resize(); fireEvent.scroll(node); flush(); + expect(node.scrollTop).toBe(870); + expect(row.getBoundingClientRect().top).toBe(-20); + }); + + it('ignores upward input when neither viewport has scrollable content', () => { + const { node, metrics } = setup(); + metrics.height = metrics.viewport; node.scrollTop = 0; + fireEvent.scroll(node); resize(); flush(); + fireEvent.wheel(node, { deltaY: -30 }); + expect(api.isPaused).toBe(false); + expect(api.canReturnToLatest).toBe(false); + metrics.height += 500; resize(); flush(); + expect(node.scrollTop).toBe(500); + }); + + it('positions history once with automatic following disabled, then retains the reader', () => { + const { view, node, metrics } = setup(); + view.rerender(); + node.scrollTop = 0; + act(() => api.scheduleInitialPosition()); + view.rerender(); + flush(); + expect(node.scrollTop).toBe(800); + metrics.height += 100; resize(); flush(); + expect(node.scrollTop).toBe(800); + }); + + it('lets user input cancel an initial position queued with automatic following disabled', () => { + const { view, node } = setup(); + view.rerender(); + act(() => api.scheduleInitialPosition()); + fireEvent.wheel(node, { deltaY: -30 }); + node.scrollTop = 770; fireEvent.scroll(node); flush(); + expect(node.scrollTop).toBe(770); + }); + + 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('does not replay an unreachable anchor correction after content shrinks and regrows', () => { + const { node, metrics } = setup(); + const row = node.querySelector('[data-message-key="reading"]')!; + row.getBoundingClientRect = () => ({ top: 750 - metrics.top, bottom: 1000 - metrics.top }) as DOMRect; + fireEvent.wheel(node, { deltaY: -30 }); node.scrollTop = 770; fireEvent.scroll(node); + metrics.height = 900; node.scrollTop = 700; + resize(); fireEvent.scroll(node); + metrics.height = 1100; resize(); flush(); + expect(node.scrollTop).toBe(700); + 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); + Object.defineProperties(inner, { scrollHeight: { value: 500 }, clientHeight: { value: 200 }, scrollTop: { value: 300 } }); + 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..3085a7dda --- /dev/null +++ b/ui/src/components/chat/hooks/useScrollFollow.ts @@ -0,0 +1,315 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; + +const BOTTOM_EPSILON = 2; +const VIEWPORT_SELECTOR = '[data-stream-scroll-viewport]'; +const FOLLOW_CHANGE = 'stream-scroll-follow-change'; +const controllers = new WeakMap void }>(); +export type ReadingAnchor = { key: string; offset: number }; +type UserScroll = { top: number; direction: number; dragging?: boolean }; + +/** Keep user intent, viewport geometry, and the return-to-latest affordance separate. */ +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 [canReturnToLatest, setCanReturnToLatest] = useState(false); + const [hasOverflow, setHasOverflow] = useState(false); + const pausedRef = useRef(false); + const optionsRef = useRef({ enabled, canFollow }); + optionsRef.current = { enabled, canFollow }; + const frameRef = useRef(null); + const initialFrameRef = useRef(false); + const anchorRef = useRef(null); + const metricsRef = useRef({ top: 0, height: 0, viewport: 0 }); + const programmaticTopRef = useRef(null); + const userScrollRef = useRef(null); + + const cancelFollow = useCallback(() => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + initialFrameRef.current = false; + }, []); + + const updateAvailability = useCallback(() => { + const node = containerRef.current; + if (!node) return; + setHasOverflow(node.scrollHeight - node.clientHeight > BOTTOM_EPSILON); + setCanReturnToLatest([node, ...node.querySelectorAll(VIEWPORT_SELECTOR)].some((viewport) => ( + viewport.clientHeight > 0 + && (viewport.dataset.scrollFollowPaused === 'true' || (viewport === node && !optionsRef.current.enabled)) + && viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop > BOTTOM_EPSILON + ))); + }, [containerRef]); + + const publish = useCallback(() => { + updateAvailability(); + containerRef.current?.dispatchEvent(new Event(FOLLOW_CHANGE, { bubbles: true })); + }, [containerRef, updateAvailability]); + + 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) { + const targetTop = node.scrollTop + delta; + node.scrollTop = targetTop; + programmaticTopRef.current = node.scrollTop; + // Shrinking content can make the old offset unreachable. Adopt the + // clamped position so later growth cannot replay that stale correction. + if (Math.abs(node.scrollTop - targetTop) > 0.5) captureAnchor(); + } + rememberMetrics(); + }, [containerRef, rememberMetrics, captureAnchor]); + + const getReadingAnchor = useCallback(() => anchorRef.current ? { ...anchorRef.current } : null, []); + const restoreReadingAnchor = useCallback((anchor: ReadingAnchor) => { + anchorRef.current = { ...anchor }; + if (containerRef.current) containerRef.current.dataset.readingAnchorKey = anchor.key; + restoreAnchor(); + }, [containerRef, restoreAnchor]); + + const setPaused = useCallback((paused: boolean) => { + pausedRef.current = paused; + setIsPaused(paused); + if (containerRef.current) containerRef.current.dataset.scrollFollowPaused = String(paused); + if (paused) { + cancelFollow(); + captureAnchor(); + } else { + anchorRef.current = null; + if (containerRef.current) delete containerRef.current.dataset.readingAnchorKey; + } + publish(); + }, [cancelFollow, captureAnchor, containerRef, publish]); + + // A resize can run before the browser delivers scroll. Account for the actual + // user displacement before any anchor restoration, including in that order. + const acceptUserDisplacement = useCallback(() => { + const node = containerRef.current; + const input = userScrollRef.current; + if (!node || !input) return false; + const delta = node.scrollTop - input.top; + if (!delta || (input.direction && Math.sign(delta) !== input.direction)) return false; + const previous = metricsRef.current; + const layoutChanged = node.scrollHeight !== previous.height || node.clientHeight !== previous.viewport; + if (layoutChanged && anchorRef.current) { + // Preserve the reader's gesture AND any independent insertion above it. + anchorRef.current.offset -= delta; + restoreAnchor(); + } + captureAnchor(); + userScrollRef.current = input.dragging ? { ...input, top: node.scrollTop } : null; + rememberMetrics(); + return true; + }, [containerRef, captureAnchor, rememberMetrics, restoreAnchor]); + + const writeBottom = useCallback(() => { + const node = containerRef.current; + if (!node) return; + node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + programmaticTopRef.current = node.scrollTop; + if (!optionsRef.current.enabled) captureAnchor(); + rememberMetrics(); + publish(); + }, [containerRef, captureAnchor, rememberMetrics, publish]); + + const queuePosition = useCallback((initial: boolean) => { + const allowed = () => !pausedRef.current + && (initial || optionsRef.current.enabled) && (optionsRef.current.canFollow?.() ?? true); + if (!allowed()) return; + if (initial) cancelFollow(); + if (frameRef.current !== null) return; + initialFrameRef.current = initial; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + initialFrameRef.current = false; + if (allowed()) writeBottom(); + }); + }, [cancelFollow, writeBottom]); + const scheduleFollow = useCallback(() => queuePosition(false), [queuePosition]); + const scheduleInitialPosition = useCallback(() => queuePosition(true), [queuePosition]); + const getIsPaused = useCallback(() => pausedRef.current, []); + const pause = useCallback(() => setPaused(true), [setPaused]); + + const resumeOwnViewport = useCallback(() => { + cancelFollow(); + userScrollRef.current = null; + setPaused(false); + writeBottom(); + scheduleFollow(); + }, [cancelFollow, setPaused, writeBottom, scheduleFollow]); + const scrollToBottom = useCallback(() => { + // The global control represents all visible nested viewports as well. + const nested = containerRef.current?.querySelectorAll(VIEWPORT_SELECTOR) ?? []; + Array.from(nested).reverse().forEach((viewport) => { + if (viewport.clientHeight > 0) controllers.get(viewport)?.resume(); + }); + resumeOwnViewport(); + }, [containerRef, resumeOwnViewport]); + + useLayoutEffect(() => { + cancelFollow(); + anchorRef.current = null; + if (containerRef.current) delete containerRef.current.dataset.readingAnchorKey; + programmaticTopRef.current = null; + userScrollRef.current = null; + setPaused(false); + rememberMetrics(); + return cancelFollow; + }, [scopeKey, cancelFollow, containerRef, rememberMetrics, setPaused]); + + useLayoutEffect(() => { + const node = containerRef.current; + if (!node) return; + let touchY: number | null = null; + const atBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight <= BOTTOM_EPSILON; + const sourceViewport = (target: EventTarget | null) => { + let source = target instanceof Element ? target.closest(VIEWPORT_SELECTOR) ?? node : node; + // A short nested block cannot consume the gesture; its scrollable + // ancestor owns it. A nested block with overflow keeps that ownership. + while (source !== node && source.scrollHeight - source.clientHeight <= BOTTOM_EPSILON) { + source = source.parentElement?.closest(VIEWPORT_SELECTOR) ?? node; + } + return source; + }; + const beginUpwardInput = (target: EventTarget | null) => { + const source = sourceViewport(target); + if (source.scrollHeight - source.clientHeight <= BOTTOM_EPSILON) return; + setPaused(true); + if (source === node) { + programmaticTopRef.current = null; + userScrollRef.current = { top: node.scrollTop, direction: -1 }; + } + }; + const beginDownwardInput = (target: EventTarget | null) => { + if (sourceViewport(target) !== node) return; + if (atBottom()) scrollToBottom(); + else userScrollRef.current = { top: node.scrollTop, direction: 1 }; + }; + const wheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + if (event.deltaY < 0) beginUpwardInput(event.target); + else if (event.deltaY > 0) beginDownwardInput(event.target); + }; + 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) beginUpwardInput(event.target); + else if (nextY !== null && touchY !== null && nextY < touchY) beginDownwardInput(event.target); + 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)) beginUpwardInput(target); + else if (event.key === 'End' && sourceViewport(target) === node) scrollToBottom(); + else if (['ArrowDown', 'PageDown', ' '].includes(event.key)) beginDownwardInput(target); + }; + const pointerDown = (event: PointerEvent) => { + // A click in blank content is not a scrollbar drag. + const source = sourceViewport(event.target); + if (event.target === source && source.scrollHeight > source.clientHeight + && event.clientX >= source.getBoundingClientRect().right - 16) { + setPaused(true); + if (source === node) userScrollRef.current = { top: node.scrollTop, direction: 0, dragging: true }; + } + }; + const pointerUp = () => { if (userScrollRef.current?.dragging) userScrollRef.current = null; }; + const scroll = () => { + const previous = metricsRef.current; + const top = node.scrollTop; + const userMoved = acceptUserDisplacement(); + if (!userMoved && programmaticTopRef.current !== null && Math.abs(top - programmaticTopRef.current) < 0.5) { + programmaticTopRef.current = null; + rememberMetrics(); + publish(); + return; + } + programmaticTopRef.current = null; + const layoutChanged = node.scrollHeight !== previous.height || node.clientHeight !== previous.viewport; + if (userMoved || !layoutChanged) { + if (!userMoved && top < previous.top) setPaused(true); + else if (top > previous.top && atBottom()) scrollToBottom(); + if (pausedRef.current || !optionsRef.current.enabled) captureAnchor(); + } + rememberMetrics(); + publish(); + }; + controllers.set(node, { resume: resumeOwnViewport }); + node.addEventListener(FOLLOW_CHANGE, updateAvailability); + 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); + window.addEventListener('pointerup', pointerUp); + node.addEventListener('scroll', scroll, { passive: true }); + rememberMetrics(); + return () => { + controllers.delete(node); + node.removeEventListener(FOLLOW_CHANGE, updateAvailability); + 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); + window.removeEventListener('pointerup', pointerUp); + node.removeEventListener('scroll', scroll); + }; + }, [containerRef, scopeKey, contentKey, acceptUserDisplacement, captureAnchor, rememberMetrics, publish, resumeOwnViewport, scrollToBottom, setPaused, updateAvailability]); + + useLayoutEffect(() => { + const node = containerRef.current; + const content = contentSelector ? node?.querySelector(contentSelector) : node?.firstElementChild; + if (!node || !content || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + acceptUserDisplacement(); + if (pausedRef.current || !optionsRef.current.enabled) restoreAnchor(); + else scheduleFollow(); + publish(); + }); + observer.observe(content); + observer.observe(node); + return () => observer.disconnect(); + }, [containerRef, contentSelector, scopeKey, contentKey, acceptUserDisplacement, restoreAnchor, scheduleFollow, publish]); + + useLayoutEffect(() => { + acceptUserDisplacement(); + if (pausedRef.current || !enabled) { + if (pausedRef.current || !initialFrameRef.current) cancelFollow(); + restoreAnchor(); + if (!anchorRef.current) captureAnchor(); + } else scheduleFollow(); + publish(); + }); + + return { isPaused, canReturnToLatest, hasOverflow, setPaused, getIsPaused, pause, scrollToBottom, scheduleFollow, scheduleInitialPosition, cancelFollow, captureAnchor, getReadingAnchor, restoreReadingAnchor }; +} 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,