From d9db7caea0c30ad7de2425a546cc64c548771c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Jul 2026 14:48:23 +0800 Subject: [PATCH] feat(desktop): add conversation navigation #782 --- .../chat/ConversationNavigator.test.tsx | 137 +++++++++++ .../components/chat/ConversationNavigator.tsx | 161 +++++++++++++ .../src/components/chat/MessageList.test.tsx | 216 ++++++++++++++++- desktop/src/components/chat/MessageList.tsx | 227 ++++++++++++++++-- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/theme/globals.css | 21 ++ 10 files changed, 756 insertions(+), 16 deletions(-) create mode 100644 desktop/src/components/chat/ConversationNavigator.test.tsx create mode 100644 desktop/src/components/chat/ConversationNavigator.tsx diff --git a/desktop/src/components/chat/ConversationNavigator.test.tsx b/desktop/src/components/chat/ConversationNavigator.test.tsx new file mode 100644 index 00000000..56534d5d --- /dev/null +++ b/desktop/src/components/chat/ConversationNavigator.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import type { UIMessage } from '../../types/chat' +import { + buildConversationNavigationItems, + ConversationNavigator, + type ConversationNavigationSource, +} from './ConversationNavigator' + +function source(message: UIMessage, renderIndex: number): ConversationNavigationSource { + return { + message, + renderIndex, + renderItemKey: message.id, + } +} + +describe('buildConversationNavigationItems', () => { + it('keeps only visible user and assistant messages in transcript order', () => { + const items = buildConversationNavigationItems([ + source({ id: 'user-1', type: 'user_text', content: ' Review the API ', timestamp: 1 }, 0), + source({ id: 'thinking-1', type: 'thinking', content: 'hidden', timestamp: 2 }, 1), + source({ id: 'assistant-empty', type: 'assistant_text', content: ' ', timestamp: 3 }, 2), + source({ id: 'assistant-1', type: 'assistant_text', content: '**API** review complete', timestamp: 4 }, 3), + source({ id: 'system-1', type: 'system', content: 'hidden', timestamp: 5 }, 4), + ]) + + expect(items).toEqual([ + { + id: 'user-1', + renderItemKey: 'user-1', + renderIndex: 0, + role: 'user', + preview: 'Review the API', + attachmentCount: 0, + }, + { + id: 'assistant-1', + renderItemKey: 'assistant-1', + renderIndex: 3, + role: 'assistant', + preview: 'API review complete', + attachmentCount: 0, + }, + ]) + }) + + it('counts user attachments and flattens markdown into preview text', () => { + const items = buildConversationNavigationItems([ + source({ + id: 'user-files', + type: 'user_text', + content: '> Please inspect [`MessageList`](https://example.com)\n\n```ts\nconst ready = true\n```', + timestamp: 1, + attachments: [ + { type: 'file', name: 'one.ts', mimeType: 'text/plain' }, + { type: 'file', name: 'two.ts', mimeType: 'text/plain' }, + ], + }, 0), + ]) + + expect(items[0]).toMatchObject({ + preview: 'Please inspect MessageList const ready = true', + attachmentCount: 2, + }) + }) + + it('bounds previews for very long messages', () => { + const items = buildConversationNavigationItems([ + source({ id: 'long', type: 'assistant_text', content: 'long answer '.repeat(200), timestamp: 1 }, 0), + ]) + + expect(items[0]?.preview.length).toBeLessThanOrEqual(280) + expect(items[0]?.preview.endsWith('…')).toBe(true) + }) +}) + +describe('ConversationNavigator', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders ordered role markers and identifies the active target', () => { + render( + , + ) + + const markers = screen.getAllByRole('button') + expect(markers.map((marker) => marker.getAttribute('data-role'))).toEqual(['user', 'assistant']) + expect(markers[0]?.getAttribute('aria-current')).toBeNull() + expect(markers[1]?.getAttribute('aria-current')).toBe('location') + }) + + it('shows the preview on hover or focus and navigates on click', () => { + const onNavigate = vi.fn() + const item = { + id: 'user-1', + renderItemKey: 'user-1', + renderIndex: 0, + role: 'user' as const, + preview: 'Inspect the virtual transcript', + attachmentCount: 2, + } + render( + , + ) + + const marker = screen.getByRole('button', { name: /User message.*Inspect the virtual transcript/ }) + expect(screen.queryByTestId('conversation-navigation-preview')).toBeNull() + + fireEvent.mouseEnter(marker) + const preview = screen.getByTestId('conversation-navigation-preview') + expect(preview.parentElement).toBe(document.body) + expect(preview.textContent).toContain('User message') + expect(preview.textContent).toContain('Inspect the virtual transcript') + expect(preview.textContent).toContain('2') + + fireEvent.mouseLeave(marker) + fireEvent.focus(marker) + expect(screen.getByTestId('conversation-navigation-preview')).toBeTruthy() + + fireEvent.click(marker) + expect(onNavigate).toHaveBeenCalledWith(item) + }) +}) diff --git a/desktop/src/components/chat/ConversationNavigator.tsx b/desktop/src/components/chat/ConversationNavigator.tsx new file mode 100644 index 00000000..59919ec5 --- /dev/null +++ b/desktop/src/components/chat/ConversationNavigator.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Paperclip } from 'lucide-react' +import { useTranslation } from '../../i18n' +import type { UIMessage } from '../../types/chat' + +export type ConversationNavigationSource = { + message: UIMessage + renderItemKey: string + renderIndex: number +} + +export type ConversationNavigationItem = { + id: string + renderItemKey: string + renderIndex: number + role: 'user' | 'assistant' + preview: string + attachmentCount: number +} + +function normalizePreview(content: string) { + const normalized = content.slice(0, 2_000) + .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') + .replace(/```[a-z0-9_-]*\s*/gi, ' ') + .replace(/```/g, ' ') + .replace(/[`*_>#~]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (normalized.length <= 280) return normalized + return `${normalized.slice(0, 279).trimEnd()}…` +} + +export function buildConversationNavigationItems( + sources: ConversationNavigationSource[], +): ConversationNavigationItem[] { + return sources.flatMap(({ message, renderItemKey, renderIndex }) => { + if (message.type !== 'user_text' && message.type !== 'assistant_text') return [] + const preview = normalizePreview(message.content) + if (!preview) return [] + + return [{ + id: message.id, + renderItemKey, + renderIndex, + role: message.type === 'user_text' ? 'user' : 'assistant', + preview, + attachmentCount: message.type === 'user_text' ? message.attachments?.length ?? 0 : 0, + }] + }) +} + +export function ConversationNavigator({ + items, + activeItemId, + onNavigate, +}: { + items: ConversationNavigationItem[] + activeItemId: string | null + onNavigate: (item: ConversationNavigationItem) => void +}) { + const t = useTranslation() + const [previewItemId, setPreviewItemId] = useState(null) + const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 }) + const markerRefs = useRef(new Map()) + const previewItem = items.find((item) => item.id === previewItemId) ?? null + + const openPreview = (itemId: string, marker: HTMLButtonElement) => { + const rect = marker.getBoundingClientRect() + setPreviewPosition({ + left: rect.right + 6, + top: Math.min(window.innerHeight - 88, Math.max(88, rect.top + rect.height / 2)), + }) + setPreviewItemId(itemId) + } + + useEffect(() => { + if (!activeItemId) return + markerRefs.current.get(activeItemId)?.scrollIntoView?.({ block: 'nearest' }) + }, [activeItemId]) + + return ( + + ) +} diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 49f71ce7..a4020499 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { MessageList, buildRenderModel, shouldVirtualizeRenderItems } from './MessageList' +import { + MessageList, + buildRenderModel, + buildVirtualItemOffsets, + getActiveConversationNavigationItemId, + getConversationNavigationTargetScrollTop, + shouldVirtualizeRenderItems, +} from './MessageList' +import type { ConversationNavigationItem } from './ConversationNavigator' import type { VirtualRenderItemMetric } from './virtualHeightCache' import { relativizeWorkspacePath } from './CurrentTurnChangeCard' import { sessionsApi } from '../../api/sessions' @@ -341,6 +349,173 @@ describe('MessageList nested tool calls', () => { expect(screen.getByText('latest assistant reply')).toBeTruthy() }) + it('shows the conversation navigator for normal desktop transcripts and hides it in compact mode', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 }, + { id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 }, + { id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 }, + { id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 }, + ], + }), + }, + }) + + const { rerender } = render() + expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy() + + rerender() + expect(screen.queryByRole('navigation', { name: 'Conversation navigation' })).toBeNull() + }) + + it('updates the active conversation marker while the transcript scrolls', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 }, + { id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 }, + { id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 }, + { id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 }, + ], + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + let scrollTop = 0 + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 200 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 450 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value }, + }) + + fireEvent.scroll(scroller) + expect(screen.getByRole('button', { name: /User message: First prompt/ }).getAttribute('aria-current')).toBe('location') + + scrollTop = 250 + fireEvent.scroll(scroller) + expect(screen.getByRole('button', { name: /User message: Second prompt/ }).getAttribute('aria-current')).toBe('location') + }) + + it('mounts and highlights a far virtualized message selected from the navigator', async () => { + const messages: UIMessage[] = Array.from({ length: 220 }, (_, index) => ({ + id: `${index % 2 === 0 ? 'user' : 'assistant'}-${index}`, + type: index % 2 === 0 ? 'user_text' : 'assistant_text', + content: `${index % 2 === 0 ? 'Prompt' : 'Answer'} ${index}`, + timestamp: index, + })) as UIMessage[] + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ messages }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + let scrollTop = 24_000 + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 25_000 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value }, + }) + + fireEvent.click(screen.getByRole('button', { name: /User message: Prompt 0/ })) + + await waitFor(() => expect(screen.getByText('Prompt 0')).toBeTruthy()) + expect(scrollTop).toBe(0) + expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className).toContain('chat-render-item--navigation-target') + }) + + it('resumes following new output after navigating to the latest message', async () => { + const messages: UIMessage[] = [ + { id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 }, + { id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 }, + { id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 }, + { id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 }, + ] + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ messages }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + let scrollTop = 100 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value >= 1_000_000_000 ? 600 : value }, + }) + Object.defineProperty(scroller, 'scrollTo', { + configurable: true, + value: (options: ScrollToOptions) => { scroller.scrollTop = options.top ?? 0 }, + }) + + fireEvent.click(screen.getByRole('button', { name: /Assistant message: Second answer/ })) + act(() => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages, + chatState: 'streaming', + streamingText: 'More output from the latest reply', + }), + }, + }) + }) + + await waitFor(() => expect(scrollTop).toBe(600)) + }) + + it('does not treat the last text marker as the transcript tail when tool output follows it', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 }, + { id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 }, + { id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 }, + { id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 }, + { + id: 'tool-tail', + type: 'tool_use', + toolName: 'Read', + toolUseId: 'tool-tail-use', + input: { file_path: '/tmp/example.txt' }, + timestamp: 5, + }, + ], + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + let scrollTop = 100 + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }) + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value >= 1_000_000_000 ? 600 : value }, + }) + + fireEvent.click(screen.getByRole('button', { name: /Assistant message: Second answer/ })) + + expect(scrollTop).not.toBe(600) + }) + it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => { const messages: UIMessage[] = [ { @@ -4704,3 +4879,42 @@ describe('shouldVirtualizeRenderItems', () => { } }) }) + +describe('conversation navigation layout', () => { + const metrics: VirtualRenderItemMetric[] = [ + { signature: 'a', contentWeight: 1, estimatedHeight: 100 }, + { signature: 'b', contentWeight: 1, estimatedHeight: 200 }, + { signature: 'c', contentWeight: 1, estimatedHeight: 300 }, + ] + const items: ConversationNavigationItem[] = [ + { id: 'a', renderItemKey: 'a', renderIndex: 0, role: 'user', preview: 'A', attachmentCount: 0 }, + { id: 'b', renderItemKey: 'b', renderIndex: 1, role: 'assistant', preview: 'B', attachmentCount: 0 }, + { id: 'c', renderItemKey: 'c', renderIndex: 2, role: 'user', preview: 'C', attachmentCount: 0 }, + ] + + it('uses measured heights when calculating transcript offsets', () => { + const offsets = buildVirtualItemOffsets( + ['a', 'b', 'c'], + metrics, + new Map([['b', 250]]), + ) + + expect(offsets).toEqual([0, 100, 350, 650]) + }) + + it('selects the last navigation item above the viewport reading anchor', () => { + const offsets = [0, 100, 350, 650] + + expect(getActiveConversationNavigationItemId(items, offsets, 0, 300)).toBe('a') + expect(getActiveConversationNavigationItemId(items, offsets, 0, 600)).toBe('a') + expect(getActiveConversationNavigationItemId(items, offsets, 120, 300)).toBe('b') + expect(getActiveConversationNavigationItemId(items, offsets, 330, 300)).toBe('c') + }) + + it('places navigation targets near the upper reading anchor and clamps the range', () => { + const offsets = [0, 100, 350, 650] + + expect(getConversationNavigationTargetScrollTop(items[0]!, offsets, 400, 650)).toBe(0) + expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250) + }) +}) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 6b5eface..d49b7900 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -22,6 +22,11 @@ import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' +import { + buildConversationNavigationItems, + ConversationNavigator, + type ConversationNavigationItem, +} from './ConversationNavigator' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { formatTokenCount } from '../../lib/formatTokenCount' import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks' @@ -944,6 +949,8 @@ const VIRTUAL_MAX_ITEM_HEIGHT = 24_000 // Windows WebView2 can report 1px oscillations for live chat content; don't // convert those into bottom-scroll corrections. const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2 +const CONVERSATION_NAVIGATION_MIN_ITEMS = 4 +const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message' const EMPTY_MESSAGES: UIMessage[] = [] const EMPTY_AGENT_TASK_NOTIFICATIONS: Record = {} const CHAT_SCROLL_AREA_CLASS = [ @@ -984,6 +991,8 @@ type VirtualTranscriptWindow = { beforeHeight: number afterHeight: number items: VirtualTranscriptItem[] + offsets: number[] + totalHeight: number } const sessionScrollSnapshots = new Map() @@ -1228,6 +1237,55 @@ function findVirtualEndIndex(offsets: number[], target: number) { return clampNumber(low + 1, 0, offsets.length - 1) } +export function buildVirtualItemOffsets( + itemKeys: string[], + metrics: VirtualRenderItemMetric[], + measuredHeights: Map, +) { + const offsets = new Array(itemKeys.length + 1) + offsets[0] = 0 + for (let index = 0; index < itemKeys.length; index += 1) { + const measuredHeight = measuredHeights.get(itemKeys[index]!) + const height = measuredHeight && measuredHeight > 0 + ? measuredHeight + : metrics[index]?.estimatedHeight ?? VIRTUAL_MIN_ITEM_HEIGHT + offsets[index + 1] = offsets[index]! + height + } + return offsets +} + +const CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO = 0.25 + +export function getActiveConversationNavigationItemId( + items: ConversationNavigationItem[], + offsets: number[], + scrollTop: number, + viewportHeight: number, +) { + if (items.length === 0) return null + if (scrollTop <= 1) return items[0]!.id + const readingAnchor = scrollTop + viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO + let activeItem = items[0]! + + for (const item of items) { + if ((offsets[item.renderIndex] ?? 0) > readingAnchor) break + activeItem = item + } + + return activeItem.id +} + +export function getConversationNavigationTargetScrollTop( + item: ConversationNavigationItem, + offsets: number[], + viewportHeight: number, + totalHeight: number, +) { + const targetTop = offsets[item.renderIndex] ?? 0 + const readingAnchor = viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO + return clampNumber(targetTop - readingAnchor, 0, Math.max(0, totalHeight - viewportHeight)) +} + function buildVirtualTranscriptWindow( renderItems: RenderItem[], itemKeys: string[], @@ -1236,27 +1294,19 @@ function buildVirtualTranscriptWindow( viewport: VirtualViewport, overscanPx: number, ): VirtualTranscriptWindow { + const offsets = buildVirtualItemOffsets(itemKeys, metrics, measuredHeights) + const totalHeight = offsets[renderItems.length] ?? 0 if (!shouldVirtualizeRenderItems(metrics)) { return { enabled: false, beforeHeight: 0, afterHeight: 0, items: renderItems.map((item, index) => ({ item, index })), + offsets, + totalHeight, } } - const offsets = new Array(renderItems.length + 1) - offsets[0] = 0 - for (let index = 0; index < renderItems.length; index += 1) { - const item = renderItems[index]! - const measuredHeight = measuredHeights.get(itemKeys[index]!) - const height = measuredHeight && measuredHeight > 0 - ? measuredHeight - : metrics[index]?.estimatedHeight ?? estimateRenderItemHeight(item) - offsets[index + 1] = offsets[index]! + height - } - - const totalHeight = offsets[renderItems.length] ?? 0 const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT const maxScrollTop = Math.max(0, totalHeight - viewportHeight) const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop) @@ -1273,6 +1323,8 @@ function buildVirtualTranscriptWindow( item, index: startIndex + offset, })), + offsets, + totalHeight, } } @@ -1322,10 +1374,12 @@ function VirtualSpacer({ height, position }: { height: number; position: 'top' | const MeasuredRenderItem = memo(function MeasuredRenderItem({ itemKey, onHeightChange, + highlighted, children, }: { itemKey: string onHeightChange: (itemKey: string, height: number) => void + highlighted: boolean children: ReactNode }) { const itemRef = useRef(null) @@ -1349,7 +1403,8 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
{children}
@@ -1396,6 +1451,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { ) const pendingMeasuredHeightsRef = useRef(false) const measureFlushFrameRef = useRef(null) + const navigationHighlightTimerRef = useRef(null) const lastAutoScrollAtRef = useRef(0) const lastContentResizeFollowHeightRef = useRef(null) const shouldAutoScrollRef = useRef(true) @@ -1418,6 +1474,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, }) const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0) + const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState(null) const branchActionsDisabled = isMemberSession || chatState !== 'idle' || @@ -1433,6 +1490,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (measureFlushFrameRef.current !== null) { cancelAnimationFrame(measureFlushFrameRef.current) } + if (navigationHighlightTimerRef.current !== null) { + window.clearTimeout(navigationHighlightTimerRef.current) + } }, []) const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => { @@ -1736,6 +1796,37 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }), [renderItemKeys, renderItems], ) + const conversationNavigationHistoryItems = useMemo(() => { + const sources = renderItems.flatMap((item, renderIndex) => item.kind === 'message' + ? [{ + message: item.message, + renderIndex, + renderItemKey: getRenderItemKey(item), + }] + : []) + + return buildConversationNavigationItems(sources) + }, [renderItems]) + const streamingConversationNavigationItem = useMemo(() => { + if (!streamingText.trim()) return null + + return buildConversationNavigationItems([{ + message: { + id: `${STREAMING_ASSISTANT_NAVIGATION_KEY}-${resolvedSessionId ?? 'session'}`, + type: 'assistant_text', + content: streamingText, + timestamp: 0, + }, + renderIndex: renderItems.length, + renderItemKey: STREAMING_ASSISTANT_NAVIGATION_KEY, + }])[0] ?? null + }, [renderItems, resolvedSessionId, streamingText]) + const conversationNavigationItems = useMemo( + () => streamingConversationNavigationItem + ? [...conversationNavigationHistoryItems, streamingConversationNavigationItem] + : conversationNavigationHistoryItems, + [conversationNavigationHistoryItems, streamingConversationNavigationItem], + ) const virtualTranscriptWindow = useMemo( () => buildVirtualTranscriptWindow( renderItems, @@ -1747,6 +1838,19 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { ), [measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport], ) + const activeConversationNavigationItemId = useMemo( + () => getActiveConversationNavigationItemId( + conversationNavigationItems, + virtualTranscriptWindow.offsets, + virtualViewport.scrollTop, + virtualViewport.viewportHeight, + ), + [conversationNavigationItems, virtualTranscriptWindow.offsets, virtualViewport], + ) + const showConversationNavigator = + !compact && + !isTouchH5Document() && + conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS const confirmTurnCard = useMemo( () => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null, [turnUndoConfirmTargetId, visibleTurnChangeCards], @@ -1952,6 +2056,81 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { return result }, [toolResultMap]) + const handleNavigateToConversationItem = useCallback((item: ConversationNavigationItem) => { + const container = scrollContainerRef.current + if (!container) return + + const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + const isTranscriptTail = + item.renderItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY || + item.renderIndex === renderItems.length - 1 + setHighlightedNavigationItemKey(item.renderItemKey) + + const scheduleHighlightClear = () => { + if (navigationHighlightTimerRef.current !== null) { + window.clearTimeout(navigationHighlightTimerRef.current) + } + navigationHighlightTimerRef.current = window.setTimeout(() => { + setHighlightedNavigationItemKey((current) => current === item.renderItemKey ? null : current) + navigationHighlightTimerRef.current = null + }, 1400) + } + + if (isTranscriptTail) { + scrollToBottom('auto') + requestAnimationFrame(scheduleHighlightClear) + return + } + + const targetScrollTop = getConversationNavigationTargetScrollTop( + item, + virtualTranscriptWindow.offsets, + viewportHeight, + virtualTranscriptWindow.totalHeight, + ) + const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + const isNearby = Math.abs(container.scrollTop - targetScrollTop) <= viewportHeight * 1.25 + + shouldAutoScrollRef.current = false + setShowJumpToLatest(true) + ignoreProgrammaticScrollUntilRef.current = performance.now() + 250 + ignoreProgrammaticScrollTopRef.current = targetScrollTop + + if (isNearby && !prefersReducedMotion && typeof container.scrollTo === 'function') { + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }) + } else { + setScrollTopWithoutLayoutRead(container, targetScrollTop) + } + setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight }) + + requestAnimationFrame(() => { + const targetNode = Array.from( + scrollContentRef.current?.querySelectorAll('[data-chat-render-item-key]') ?? [], + ).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey) + + if (targetNode) { + const targetRect = targetNode.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + if (targetRect.height > 0) { + const correction = targetRect.top - containerRect.top - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO + if (Math.abs(correction) >= 1) { + setScrollTopWithoutLayoutRead(container, container.scrollTop + correction) + syncVirtualViewportFromContainer(container) + } + } + } + + scheduleHighlightClear() + }) + }, [ + renderItems.length, + scrollToBottom, + syncVirtualViewportFromContainer, + virtualTranscriptWindow.offsets, + virtualTranscriptWindow.totalHeight, + virtualViewport.viewportHeight, + ]) + const renderTranscriptItem = (item: RenderItem, index: number) => { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] @@ -2027,11 +2206,16 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { key={itemKey} itemKey={itemKey} onHeightChange={handleVirtualItemHeightChange} + highlighted={highlightedNavigationItemKey === itemKey} > {content} ) : ( -
+
{content}
) @@ -2042,7 +2226,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { ) : null} {streamingText.trim() && ( - +
+ +
)} {chatState === 'compacting' && !hasCompactingDivider && ( @@ -2067,6 +2256,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
+ {showConversationNavigator ? ( + + ) : null} + {showJumpToLatest && (