From 9734ad62b1abcf35b6b37f4b92c167b39f095737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 13 Jul 2026 13:03:01 +0800 Subject: [PATCH] fix(desktop): add proximity wave to conversation navigation #782 --- .../chat/ConversationNavigator.test.tsx | 66 ++++++++++++-- .../components/chat/ConversationNavigator.tsx | 87 +++++++++++++++---- .../src/components/chat/MessageList.test.tsx | 10 ++- desktop/src/components/chat/MessageList.tsx | 24 ++--- 4 files changed, 148 insertions(+), 39 deletions(-) diff --git a/desktop/src/components/chat/ConversationNavigator.test.tsx b/desktop/src/components/chat/ConversationNavigator.test.tsx index 2cf1cb9a..5e15eb10 100644 --- a/desktop/src/components/chat/ConversationNavigator.test.tsx +++ b/desktop/src/components/chat/ConversationNavigator.test.tsx @@ -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( + ({ + 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') }) }) diff --git a/desktop/src/components/chat/ConversationNavigator.tsx b/desktop/src/components/chat/ConversationNavigator.tsx index 2c703118..f9527c59 100644 --- a/desktop/src/components/chat/ConversationNavigator.tsx +++ b/desktop/src/components/chat/ConversationNavigator.tsx @@ -25,28 +25,52 @@ const NAVIGATION_MODE_STYLES: Record = { 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(null) const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 }) + const [pointerIndex, setPointerIndex] = useState(null) + const [focusIndex, setFocusIndex] = useState(null) const markerRefs = useRef(new Map()) 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}`} > -
- {items.map((item) => { +
{ + 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 (
@@ -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}`} >