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
@@ -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)
})
})
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
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',
+2
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': '今すぐ誘導',
+2
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': '지금 가이드',
+2
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': '立即引導',
+2
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': '立即引导',
+21
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. */