feat(desktop): add conversation navigation #782

This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 14:48:23 +08:00
parent d1e684c419
commit d9db7caea0
10 changed files with 756 additions and 16 deletions

View File

@ -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(
<ConversationNavigator
items={[
{ id: 'user-1', renderItemKey: 'user-1', renderIndex: 0, role: 'user', preview: 'First prompt', attachmentCount: 0 },
{ id: 'assistant-1', renderItemKey: 'assistant-1', renderIndex: 1, role: 'assistant', preview: 'First answer', attachmentCount: 0 },
]}
activeItemId="assistant-1"
onNavigate={vi.fn()}
/>,
)
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(
<ConversationNavigator
items={[item]}
activeItemId="user-1"
onNavigate={onNavigate}
/>,
)
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)
})
})

View File

@ -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<string | null>(null)
const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 })
const markerRefs = useRef(new Map<string, HTMLButtonElement>())
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 (
<nav
data-testid="conversation-navigator"
aria-label={t('chat.conversationNavigator.label')}
className="absolute left-2 top-1/2 z-30 flex max-h-[64%] -translate-y-1/2 flex-col overflow-visible"
>
<div className="conversation-navigation-scroll flex max-h-full w-10 flex-col items-start gap-0.5 overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const roleLabel = item.role === 'user'
? t('chat.userMessageReference')
: t('chat.assistantMessageReference')
const isActive = item.id === activeItemId
return (
<div key={item.id} className="relative flex shrink-0 items-center">
<button
ref={(node) => {
if (node) markerRefs.current.set(item.id, node)
else markerRefs.current.delete(item.id)
}}
type="button"
data-role={item.role}
aria-label={`${roleLabel}: ${item.preview}`}
aria-current={isActive ? 'location' : undefined}
aria-describedby={previewItemId === item.id ? 'conversation-navigation-preview' : undefined}
onMouseEnter={(event) => openPreview(item.id, event.currentTarget)}
onMouseLeave={(event) => {
if (document.activeElement !== event.currentTarget) setPreviewItemId(null)
}}
onFocus={(event) => openPreview(item.id, event.currentTarget)}
onBlur={() => setPreviewItemId(null)}
onClick={() => onNavigate(item)}
className="group flex h-4 w-10 items-center rounded-sm pl-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span
aria-hidden="true"
className={[
'block h-0.5 rounded-full transition-[width,background-color,opacity] duration-150',
isActive
? 'w-5 bg-[var(--color-brand)] opacity-100'
: item.role === 'user'
? 'w-4 bg-[var(--color-text-secondary)] opacity-75 group-hover:w-5 group-hover:bg-[var(--color-text-primary)] group-hover:opacity-100'
: 'w-2.5 bg-[var(--color-outline)] opacity-65 group-hover:w-4 group-hover:bg-[var(--color-text-secondary)] group-hover:opacity-100',
].join(' ')}
/>
</button>
</div>
)
})}
</div>
{previewItem ? createPortal(
<div
id="conversation-navigation-preview"
data-testid="conversation-navigation-preview"
role="tooltip"
className="fixed z-50 w-[min(320px,calc(100vw-88px))] -translate-y-1/2 rounded-xl border border-[var(--color-border)]/80 bg-[var(--color-surface-container-lowest)] px-3.5 py-3 text-left shadow-[var(--shadow-dropdown)]"
style={{ left: previewPosition.left, top: previewPosition.top }}
>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{previewItem.role === 'user' ? t('chat.userMessageReference') : t('chat.assistantMessageReference')}
</div>
<p className="line-clamp-3 text-[13px] leading-5 text-[var(--color-text-primary)]">
{previewItem.preview}
</p>
{previewItem.attachmentCount > 0 ? (
<div
aria-label={t('chat.conversationNavigator.attachments', { count: previewItem.attachmentCount })}
className="mt-2 flex items-center gap-1 text-[11px] text-[var(--color-text-tertiary)]"
>
<Paperclip size={12} strokeWidth={2} aria-hidden="true" />
<span>{previewItem.attachmentCount}</span>
</div>
) : null}
</div>,
document.body,
) : null}
</nav>
)
}

View File

@ -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(<MessageList />)
expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy()
rerender(<MessageList compact />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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)
})
})

View File

@ -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<string, AgentTaskNotification> = {}
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<string, SessionScrollSnapshot>()
@ -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<string, number>,
) {
const offsets = new Array<number>(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<number>(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<HTMLDivElement>(null)
@ -1349,7 +1403,8 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
<div
ref={itemRef}
data-virtual-message-item={itemKey}
className={CHAT_RENDER_ITEM_CLASS}
data-chat-render-item-key={itemKey}
className={`${CHAT_RENDER_ITEM_CLASS} ${highlighted ? 'chat-render-item--navigation-target' : ''}`}
>
{children}
</div>
@ -1396,6 +1451,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const navigationHighlightTimerRef = useRef<number | null>(null)
const lastAutoScrollAtRef = useRef(0)
const lastContentResizeFollowHeightRef = useRef<number | null>(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<string | null>(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<HTMLElement>('[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}
</MeasuredRenderItem>
) : (
<div key={itemKey} className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv`}>
<div
key={itemKey}
data-chat-render-item-key={itemKey}
className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv ${highlightedNavigationItemKey === itemKey ? 'chat-render-item--navigation-target' : ''}`}
>
{content}
</div>
)
@ -2042,7 +2226,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
) : null}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
<div
data-chat-render-item-key={STREAMING_ASSISTANT_NAVIGATION_KEY}
className={highlightedNavigationItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY ? 'chat-render-item--navigation-target' : ''}
>
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
</div>
)}
{chatState === 'compacting' && !hasCompactingDivider && (
@ -2067,6 +2256,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
</div>
</div>
{showConversationNavigator ? (
<ConversationNavigator
items={conversationNavigationItems}
activeItemId={activeConversationNavigationItemId}
onNavigate={handleNavigateToConversationItem}
/>
) : null}
{showJumpToLatest && (
<button
type="button"

View File

@ -1196,6 +1196,8 @@ export const en = {
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
'chat.userMessageReference': 'User message',
'chat.assistantMessageReference': 'Assistant message',
'chat.conversationNavigator.label': 'Conversation navigation',
'chat.conversationNavigator.attachments': '{count} attachments',
'chat.slashCommands': 'Slash commands',
'chat.pendingMessageGuide': 'Guide',
'chat.pendingMessageGuideNow': 'Guide now',

View File

@ -1198,6 +1198,8 @@ export const jp: Record<TranslationKey, string> = {
'chat.branchError': 'このメッセージから分岐できませんでした。詳細: {detail}',
'chat.userMessageReference': 'ユーザーメッセージ',
'chat.assistantMessageReference': 'アシスタントメッセージ',
'chat.conversationNavigator.label': '会話ナビゲーション',
'chat.conversationNavigator.attachments': '添付ファイル {count} 件',
'chat.slashCommands': 'スラッシュコマンド',
'chat.pendingMessageGuide': '誘導',
'chat.pendingMessageGuideNow': '今すぐ誘導',

View File

@ -1198,6 +1198,8 @@ export const kr: Record<TranslationKey, string> = {
'chat.branchError': '이 메시지에서 분기할 수 없습니다. 세부 정보: {detail}',
'chat.userMessageReference': '사용자 메시지',
'chat.assistantMessageReference': '어시스턴트 메시지',
'chat.conversationNavigator.label': '대화 탐색',
'chat.conversationNavigator.attachments': '첨부 파일 {count}개',
'chat.slashCommands': '슬래시 명령',
'chat.pendingMessageGuide': '가이드',
'chat.pendingMessageGuideNow': '지금 가이드',

View File

@ -1198,6 +1198,8 @@ export const zh: Record<TranslationKey, string> = {
'chat.branchError': '從該訊息 Fork 新對話失敗。詳情:{detail}',
'chat.userMessageReference': '使用者訊息',
'chat.assistantMessageReference': 'AI 回覆',
'chat.conversationNavigator.label': '對話導覽',
'chat.conversationNavigator.attachments': '{count} 個附件',
'chat.slashCommands': '斜槓命令',
'chat.pendingMessageGuide': '引導',
'chat.pendingMessageGuideNow': '立即引導',

View File

@ -1198,6 +1198,8 @@ export const zh: Record<TranslationKey, string> = {
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.conversationNavigator.label': '对话导航',
'chat.conversationNavigator.attachments': '{count} 个附件',
'chat.slashCommands': '斜杠命令',
'chat.pendingMessageGuide': '引导',
'chat.pendingMessageGuideNow': '立即引导',

View File

@ -581,6 +581,7 @@
--color-text-primary-a78: rgba(27, 28, 26, 0.78);
--color-surface-hover-a34: rgba(233, 232, 228, 0.34);
--color-surface-hover-a54: rgba(233, 232, 228, 0.54);
--color-chat-navigation-target: rgba(143, 72, 47, 0.10);
--color-outline-a72: rgba(135, 115, 109, 0.72);
--color-outline-a78: rgba(135, 115, 109, 0.78);
--color-outline-a92: rgba(135, 115, 109, 0.92);
@ -775,6 +776,7 @@
--color-text-primary-a78: rgba(17, 24, 39, 0.78);
--color-surface-hover-a34: rgba(241, 244, 247, 0.34);
--color-surface-hover-a54: rgba(241, 244, 247, 0.54);
--color-chat-navigation-target: rgba(143, 72, 47, 0.10);
--color-outline-a72: rgba(139, 152, 167, 0.72);
--color-outline-a78: rgba(139, 152, 167, 0.78);
--color-outline-a92: rgba(139, 152, 167, 0.92);
@ -963,6 +965,7 @@
--color-text-primary-a78: rgba(229, 226, 225, 0.78);
--color-surface-hover-a34: rgba(53, 53, 52, 0.34);
--color-surface-hover-a54: rgba(53, 53, 52, 0.54);
--color-chat-navigation-target: rgba(255, 181, 159, 0.10);
--color-outline-a72: rgba(141, 127, 122, 0.72);
--color-outline-a78: rgba(141, 127, 122, 0.78);
--color-outline-a92: rgba(141, 127, 122, 0.92);
@ -1482,6 +1485,24 @@ button, input, textarea, select, a, [role="button"] {
contain-intrinsic-size: auto 300px;
}
@keyframes chat-navigation-target-pulse {
0% { background: transparent; }
22% { background: var(--color-chat-navigation-target); }
100% { background: transparent; }
}
.chat-render-item--navigation-target {
border-radius: var(--radius-lg);
animation: chat-navigation-target-pulse 1.4s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.chat-render-item--navigation-target {
animation: none;
background: var(--color-chat-navigation-target);
}
}
/* Same WebKit paint-skipping trick for trace tree rows (see .chat-render-item--cv
above). Tree rows are fixed-density 34px single-line items, so the intrinsic
size estimate is exact and scrolling never drifts. */