mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): refine conversation navigation #782
This commit is contained in:
parent
8cf1783159
commit
74bdb3727d
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user