mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
feat(desktop): add conversation navigation #782
This commit is contained in:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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': '今すぐ誘導',
|
||||
|
||||
@@ -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': '지금 가이드',
|
||||
|
||||
@@ -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': '立即引導',
|
||||
|
||||
@@ -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': '立即引导',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user