fix(desktop): refine conversation navigation #782

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 11:22:37 +08:00
parent 8cf1783159
commit 74bdb3727d
4 changed files with 204 additions and 12 deletions

View File

@ -84,6 +84,7 @@ describe('ConversationNavigator', () => {
it('renders ordered role markers and identifies the active target', () => {
render(
<ConversationNavigator
mode="full"
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 },
@ -97,6 +98,55 @@ describe('ConversationNavigator', () => {
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')
const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]'))
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('full')
expect(markerBars.every((bar) => bar?.className.includes('w-3'))).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('group-hover:w-5'))).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('group-focus-visible:w-5'))).toBe(true)
expect(markerBars[1]?.className).toContain('bg-[var(--color-brand)]')
expect(markerBars[1]?.className.split(/\s+/)).not.toContain('w-5')
})
it('uses equal shorter marker geometry in compact mode', () => {
render(
<ConversationNavigator
mode="compact"
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="user-1"
onNavigate={vi.fn()}
/>,
)
const markers = screen.getAllByRole('button')
const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]'))
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('compact')
expect(markerBars.every((bar) => bar?.className.includes('w-2.5'))).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('group-hover:w-4'))).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('motion-reduce:transition-none'))).toBe(true)
})
it('uses an edge-sized lane when the transcript becomes narrow', () => {
render(
<ConversationNavigator
mode="edge"
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')
const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]'))
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('edge')
expect(markerBars.every((bar) => bar?.className.includes('w-1.5'))).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('group-hover:w-3'))).toBe(true)
})
it('shows the preview on hover or focus and navigates on click', () => {
@ -111,6 +161,7 @@ describe('ConversationNavigator', () => {
}
render(
<ConversationNavigator
mode="full"
items={[item]}
activeItemId="user-1"
onNavigate={onNavigate}

View File

@ -19,6 +19,34 @@ export type ConversationNavigationItem = {
attachmentCount: number
}
export type ConversationNavigationMode = 'full' | 'compact' | 'edge'
const NAVIGATION_MODE_STYLES: Record<ConversationNavigationMode, {
position: string
lane: string
button: string
marker: string
}> = {
full: {
position: 'left-2',
lane: 'w-10',
button: 'w-10 pl-1.5',
marker: 'w-3 group-hover:w-5 group-focus-visible:w-5',
},
compact: {
position: 'left-1',
lane: 'w-7',
button: 'w-7 pl-1',
marker: 'w-2.5 group-hover:w-4 group-focus-visible:w-4',
},
edge: {
position: 'left-0',
lane: 'w-5',
button: 'w-5 pl-0.5',
marker: 'w-1.5 group-hover:w-3 group-focus-visible:w-3',
},
}
function normalizePreview(content: string) {
const normalized = content.slice(0, 2_000)
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
@ -51,10 +79,12 @@ export function buildConversationNavigationItems(
}
export function ConversationNavigator({
mode,
items,
activeItemId,
onNavigate,
}: {
mode: ConversationNavigationMode
items: ConversationNavigationItem[]
activeItemId: string | null
onNavigate: (item: ConversationNavigationItem) => void
@ -64,6 +94,7 @@ export function ConversationNavigator({
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 modeStyles = NAVIGATION_MODE_STYLES[mode]
const openPreview = (itemId: string, marker: HTMLButtonElement) => {
const rect = marker.getBoundingClientRect()
@ -82,10 +113,11 @@ export function ConversationNavigator({
return (
<nav
data-testid="conversation-navigator"
data-mode={mode}
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"
className={`absolute top-1/2 z-30 flex max-h-[64%] -translate-y-1/2 flex-col overflow-visible ${modeStyles.position}`}
>
<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">
<div className={`conversation-navigation-scroll flex max-h-full flex-col items-start gap-0.5 overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${modeStyles.lane}`}>
{items.map((item) => {
const roleLabel = item.role === 'user'
? t('chat.userMessageReference')
@ -111,17 +143,18 @@ export function ConversationNavigator({
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"
className={`group flex h-4 items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${modeStyles.button}`}
>
<span
aria-hidden="true"
className={[
'block h-0.5 rounded-full transition-[width,background-color,opacity] duration-150',
'block h-0.5 rounded-full transition-[width,background-color,opacity] duration-200 ease-out motion-reduce:transition-none',
modeStyles.marker,
isActive
? 'w-5 bg-[var(--color-brand)] opacity-100'
? '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',
? 'bg-[var(--color-text-secondary)] opacity-75 group-hover:bg-[var(--color-text-primary)] group-hover:opacity-100 group-focus-visible:bg-[var(--color-text-primary)] group-focus-visible:opacity-100'
: 'bg-[var(--color-outline)] opacity-65 group-hover:bg-[var(--color-text-secondary)] group-hover:opacity-100 group-focus-visible:bg-[var(--color-text-secondary)] group-focus-visible:opacity-100',
].join(' ')}
/>
</button>

View File

@ -350,7 +350,7 @@ 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', () => {
it('keeps the conversation navigator available in compact desktop transcripts', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
@ -368,7 +368,72 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy()
rerender(<MessageList compact />)
expect(screen.queryByRole('navigation', { name: 'Conversation navigation' })).toBeNull()
expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy()
})
it('adapts the conversation navigator when the chat column is resized by adjacent panels', () => {
const observers: Array<{
callback: ResizeObserverCallback
targets: Element[]
}> = []
class TestResizeObserver {
targets: Element[] = []
observe = vi.fn((target: Element) => {
this.targets.push(target)
})
unobserve = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) {
observers.push({ callback, targets: this.targets })
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
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 },
],
}),
},
})
render(<MessageList />)
const messageList = screen.getByTestId('message-list')
const scroller = messageList.querySelector('.chat-scroll-area') as HTMLElement
const layoutObserver = observers.find(({ targets }) => targets.includes(messageList))
expect(layoutObserver).toBeTruthy()
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('full')
const resizeTo = (width: number) => {
act(() => {
layoutObserver?.callback([{
target: messageList,
contentRect: { width },
} as unknown as ResizeObserverEntry], {} as ResizeObserver)
})
}
resizeTo(900)
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('compact')
expect(scroller.className.split(/\s+/)).toContain('pl-9')
resizeTo(640)
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('compact')
resizeTo(520)
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('edge')
expect(scroller.className.split(/\s+/)).toContain('pl-6')
resizeTo(1000)
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('full')
expect(scroller.className.split(/\s+/)).not.toContain('pl-9')
expect(scroller.className.split(/\s+/)).not.toContain('pl-6')
})
it('updates the active conversation marker while the transcript scrolls', () => {

View File

@ -27,6 +27,7 @@ import {
buildConversationNavigationItems,
ConversationNavigator,
type ConversationNavigationItem,
type ConversationNavigationMode,
} from './ConversationNavigator'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { formatTokenCount } from '../../lib/formatTokenCount'
@ -952,6 +953,8 @@ const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2
const USER_SCROLL_INTENT_WINDOW_MS = 500
const CONVERSATION_NAVIGATION_MIN_ITEMS = 4
const CONVERSATION_NAVIGATION_FULL_MIN_WIDTH_PX = 960
const CONVERSATION_NAVIGATION_COMPACT_MIN_WIDTH_PX = 560
const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message'
const EMPTY_MESSAGES: UIMessage[] = []
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
@ -1466,6 +1469,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
chatState === 'tool_executing' ||
hasPendingPermissionCard ||
(chatState === 'thinking' && Boolean(activeThinkingId))
const messageListRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef<Map<string, number>>(
@ -1503,6 +1507,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState<string | null>(null)
const [messageListWidth, setMessageListWidth] = useState<number | null>(null)
const branchActionsDisabled =
isMemberSession ||
chatState !== 'idle' ||
@ -1526,6 +1531,27 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}
}, [])
useLayoutEffect(() => {
const messageList = messageListRef.current
if (!messageList) return
const updateWidth = (width: number) => {
const roundedWidth = Math.round(width)
if (roundedWidth <= 0) return
setMessageListWidth((current) => current === roundedWidth ? current : roundedWidth)
}
updateWidth(messageList.getBoundingClientRect().width || messageList.clientWidth)
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver((entries) => {
const entry = entries.find((candidate) => candidate.target === messageList)
if (entry) updateWidth(entry.contentRect.width)
})
observer.observe(messageList)
return () => observer.disconnect()
}, [])
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
const nextScrollTop = container.scrollTop
const nextViewportHeight = container.clientHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
@ -1920,10 +1946,26 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
),
[conversationNavigationItems, virtualTranscriptWindow.offsets, virtualViewport],
)
const conversationNavigationMode: ConversationNavigationMode =
messageListWidth === null || messageListWidth >= CONVERSATION_NAVIGATION_FULL_MIN_WIDTH_PX
? 'full'
: messageListWidth >= CONVERSATION_NAVIGATION_COMPACT_MIN_WIDTH_PX
? 'compact'
: 'edge'
const showConversationNavigator =
!compact &&
!isTouchH5Document() &&
conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS
const chatScrollPaddingClass = compact
? showConversationNavigator && conversationNavigationMode === 'compact'
? 'pb-5 pl-9 pr-3 py-3'
: showConversationNavigator && conversationNavigationMode === 'edge'
? 'pb-5 pl-6 pr-3 py-3'
: 'px-3 py-3 pb-5'
: showConversationNavigator && conversationNavigationMode === 'compact'
? 'pl-9 pr-4 py-4'
: showConversationNavigator && conversationNavigationMode === 'edge'
? 'pl-6 pr-4 py-4'
: 'px-4 py-4'
const confirmTurnCard = useMemo(
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnUndoConfirmTargetId, visibleTurnChangeCards],
@ -2331,7 +2373,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}
return (
<div className="relative min-h-0 flex-1">
<div ref={messageListRef} data-testid="message-list" className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
@ -2339,7 +2381,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
onPointerDown={markUserScrollIntent}
onTouchStart={markUserScrollIntent}
onKeyDown={handleKeyDownScrollIntent}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${chatScrollPaddingClass}`}
>
<div
ref={scrollContentRef}
@ -2410,6 +2452,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
{showConversationNavigator ? (
<ConversationNavigator
mode={conversationNavigationMode}
items={conversationNavigationItems}
activeItemId={activeConversationNavigationItemId}
onNavigate={handleNavigateToConversationItem}