mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): add proximity wave to conversation navigation #782
This commit is contained in:
parent
fb4c4f7684
commit
9734ad62b1
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user