fix(desktop): add proximity wave to conversation navigation #782

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 13:03:01 +08:00
parent fb4c4f7684
commit 9734ad62b1
4 changed files with 148 additions and 39 deletions

View File

@ -101,11 +101,59 @@ describe('ConversationNavigator', () => {
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.every((bar) => (bar as HTMLElement).style.width === '12px')).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('transition-[width,background-color,opacity]'))).toBe(true)
expect(markerBars[1]?.className).toContain('bg-[var(--color-brand)]')
expect(markerBars[1]?.className.split(/\s+/)).not.toContain('w-5')
expect((markerBars[1] as HTMLElement).style.width).toBe('12px')
})
it('magnifies nearby markers as a continuous proximity wave', () => {
render(
<ConversationNavigator
mode="full"
items={Array.from({ length: 9 }, (_, index) => ({
id: `assistant-${index}`,
renderItemKey: `assistant-${index}`,
renderIndex: index,
role: 'assistant' as const,
preview: `Answer ${index}`,
attachmentCount: 0,
}))}
activeItemId="assistant-8"
onNavigate={vi.fn()}
/>,
)
const navigator = screen.getByTestId('conversation-navigator')
const lane = navigator.querySelector('.conversation-navigation-scroll') as HTMLElement
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
bottom: 180,
height: 180,
left: 0,
right: 56,
top: 0,
width: 56,
x: 0,
y: 0,
toJSON: () => ({}),
})
fireEvent.mouseMove(lane, { clientY: 88 })
const widths = screen.getAllByRole('button').map((marker) => (
Number.parseFloat((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width)
))
expect(widths[4]).toBe(52)
expect(widths[3]).toBeGreaterThan(widths[2]!)
expect(widths[2]).toBeGreaterThan(widths[1]!)
expect(widths[1]).toBeGreaterThan(widths[0]!)
expect(widths[0]).toBe(12)
expect(widths.slice(0, 4)).toEqual(widths.slice(5).reverse())
fireEvent.mouseLeave(lane)
expect(screen.getAllByRole('button').every((marker) => (
(marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width === '12px'
))).toBe(true)
})
it('uses equal shorter marker geometry in compact mode', () => {
@ -124,8 +172,7 @@ describe('ConversationNavigator', () => {
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 as HTMLElement).style.width === '10px')).toBe(true)
expect(markerBars.every((bar) => bar?.className.includes('motion-reduce:transition-none'))).toBe(true)
})
@ -145,8 +192,7 @@ describe('ConversationNavigator', () => {
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)
expect(markerBars.every((bar) => (bar as HTMLElement).style.width === '6px')).toBe(true)
})
it('shows the preview on hover or focus and navigates on click', () => {
@ -181,8 +227,12 @@ describe('ConversationNavigator', () => {
fireEvent.mouseLeave(marker)
fireEvent.focus(marker)
expect(screen.getByTestId('conversation-navigation-preview')).toBeTruthy()
expect((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width).toBe('52px')
fireEvent.click(marker)
expect(onNavigate).toHaveBeenCalledWith(item)
fireEvent.blur(marker)
expect((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width).toBe('12px')
})
})

View File

@ -25,28 +25,52 @@ const NAVIGATION_MODE_STYLES: Record<ConversationNavigationMode, {
position: string
lane: string
button: string
marker: string
restingWidth: number
expandedWidth: number
}> = {
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',
lane: 'w-16',
button: 'w-16 pl-1.5',
restingWidth: 12,
expandedWidth: 52,
},
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',
lane: 'w-9',
button: 'w-9 pl-1',
restingWidth: 10,
expandedWidth: 32,
},
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',
lane: 'w-6',
button: 'w-6 pl-0.5',
restingWidth: 6,
expandedWidth: 20,
},
}
const NAVIGATION_ITEM_HEIGHT_PX = 16
const NAVIGATION_ITEM_GAP_PX = 2
const NAVIGATION_LANE_PADDING_PX = 8
const NAVIGATION_WAVE_RADIUS_ITEMS = 4
function getMarkerWidth(
restingWidth: number,
expandedWidth: number,
itemIndex: number,
interactionIndex: number | null,
) {
if (interactionIndex === null) return restingWidth
const distance = Math.abs(itemIndex - interactionIndex)
if (distance >= NAVIGATION_WAVE_RADIUS_ITEMS) return restingWidth
const proximity = 1 - distance / NAVIGATION_WAVE_RADIUS_ITEMS
const easedProximity = Math.sin(proximity * Math.PI / 2) ** 2
return restingWidth + (expandedWidth - restingWidth) * easedProximity
}
function normalizePreview(content: string) {
const normalized = content.slice(0, 2_000)
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
@ -92,9 +116,12 @@ export function ConversationNavigator({
const t = useTranslation()
const [previewItemId, setPreviewItemId] = useState<string | null>(null)
const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 })
const [pointerIndex, setPointerIndex] = useState<number | null>(null)
const [focusIndex, setFocusIndex] = useState<number | null>(null)
const markerRefs = useRef(new Map<string, HTMLButtonElement>())
const previewItem = items.find((item) => item.id === previewItemId) ?? null
const modeStyles = NAVIGATION_MODE_STYLES[mode]
const interactionIndex = pointerIndex ?? focusIndex
const openPreview = (itemId: string, marker: HTMLButtonElement) => {
const rect = marker.getBoundingClientRect()
@ -117,12 +144,30 @@ export function ConversationNavigator({
aria-label={t('chat.conversationNavigator.label')}
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 flex-col items-start gap-0.5 overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${modeStyles.lane}`}>
{items.map((item) => {
<div
className={`conversation-navigation-scroll flex max-h-full flex-col items-start gap-0.5 overflow-y-auto overflow-x-hidden py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${modeStyles.lane}`}
onMouseMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
const firstItemCenter = NAVIGATION_LANE_PADDING_PX + NAVIGATION_ITEM_HEIGHT_PX / 2
const pointerOffset = event.clientY - rect.top + event.currentTarget.scrollTop
const nextPointerIndex = (pointerOffset - firstItemCenter) /
(NAVIGATION_ITEM_HEIGHT_PX + NAVIGATION_ITEM_GAP_PX)
setPointerIndex(Math.min(items.length - 1, Math.max(0, nextPointerIndex)))
}}
onMouseLeave={() => setPointerIndex(null)}
>
{items.map((item, itemIndex) => {
const roleLabel = item.role === 'user'
? t('chat.userMessageReference')
: t('chat.assistantMessageReference')
const isActive = item.id === activeItemId
const isInteractionTarget = interactionIndex !== null && Math.round(interactionIndex) === itemIndex
const markerWidth = getMarkerWidth(
modeStyles.restingWidth,
modeStyles.expandedWidth,
itemIndex,
interactionIndex,
)
return (
<div key={item.id} className="relative flex shrink-0 items-center">
@ -140,22 +185,30 @@ export function ConversationNavigator({
onMouseLeave={(event) => {
if (document.activeElement !== event.currentTarget) setPreviewItemId(null)
}}
onFocus={(event) => openPreview(item.id, event.currentTarget)}
onBlur={() => setPreviewItemId(null)}
onFocus={(event) => {
setFocusIndex(itemIndex)
openPreview(item.id, event.currentTarget)
}}
onBlur={() => {
setFocusIndex(null)
setPreviewItemId(null)
}}
onClick={() => onNavigate(item)}
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-200 ease-out motion-reduce:transition-none',
modeStyles.marker,
isActive
'block h-0.5 rounded-full transition-[width,background-color,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
isInteractionTarget
? 'bg-[var(--color-text-primary)] opacity-100'
: isActive
? 'bg-[var(--color-brand)] opacity-100'
: item.role === 'user'
? '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(' ')}
style={{ width: markerWidth }}
/>
</button>

View File

@ -409,6 +409,7 @@ describe('MessageList nested tool calls', () => {
const layoutObserver = observers.find(({ targets }) => targets.includes(messageList))
expect(layoutObserver).toBeTruthy()
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('full')
expect(scroller.className.split(/\s+/)).toContain('pl-20')
const resizeTo = (width: number) => {
act(() => {
@ -421,19 +422,20 @@ describe('MessageList nested tool calls', () => {
resizeTo(900)
expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('compact')
expect(scroller.className.split(/\s+/)).toContain('pl-9')
expect(scroller.className.split(/\s+/)).toContain('pl-12')
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')
expect(scroller.className.split(/\s+/)).toContain('pl-7')
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')
expect(scroller.className.split(/\s+/)).toContain('pl-20')
expect(scroller.className.split(/\s+/)).not.toContain('pl-12')
expect(scroller.className.split(/\s+/)).not.toContain('pl-7')
})
it('updates the active conversation marker while the transcript scrolls', () => {

View File

@ -1956,16 +1956,20 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
!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'
? showConversationNavigator && conversationNavigationMode === 'full'
? 'pb-5 pl-20 pr-3 py-3'
: showConversationNavigator && conversationNavigationMode === 'compact'
? 'pb-5 pl-12 pr-3 py-3'
: showConversationNavigator && conversationNavigationMode === 'edge'
? 'pb-5 pl-7 pr-3 py-3'
: 'px-3 py-3 pb-5'
: showConversationNavigator && conversationNavigationMode === 'full'
? 'pl-20 pr-4 py-4'
: showConversationNavigator && conversationNavigationMode === 'compact'
? 'pl-12 pr-4 py-4'
: showConversationNavigator && conversationNavigationMode === 'edge'
? 'pl-7 pr-4 py-4'
: 'px-4 py-4'
const confirmTurnCard = useMemo(
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnUndoConfirmTargetId, visibleTurnChangeCards],