Skip to content

Commit 51fff88

Browse files
committed
fix(chat): prefetch direct navigation intent
1 parent 6738e2c commit 51fff88

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ describe('ChatNavigationLink', () => {
7575
return link
7676
}
7777

78+
function pointerEvent(type: string, pointerType: 'mouse' | 'touch', init?: MouseEventInit) {
79+
const event = new MouseEvent(type, { bubbles: true, ...init })
80+
Object.defineProperty(event, 'pointerType', { value: pointerType })
81+
return event
82+
}
83+
7884
it('prefetches the route and exact history after deliberate pointer intent', () => {
7985
const link = renderLink()
8086

@@ -132,6 +138,59 @@ describe('ChatNavigationLink', () => {
132138
expect(prefetchQuery).not.toHaveBeenCalled()
133139
})
134140

141+
it('prefetches before direct mouse clicks and completed touch taps', () => {
142+
const link = renderLink()
143+
144+
act(() => link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 })))
145+
146+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
147+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
148+
149+
act(() => link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
150+
prefetchQuery.mockClear()
151+
act(() => link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 })))
152+
153+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
154+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
155+
})
156+
157+
it('cancels touch-scroll pointer intent before it can prefetch', () => {
158+
const link = renderLink()
159+
160+
act(() => {
161+
link.dispatchEvent(pointerEvent('pointerdown', 'touch', { button: 0 }))
162+
link.dispatchEvent(pointerEvent('pointercancel', 'touch', { button: 0 }))
163+
})
164+
165+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
166+
expect(prefetchQuery).not.toHaveBeenCalled()
167+
})
168+
169+
it('does not prefetch when a nested chat action is pressed', () => {
170+
act(() => {
171+
root.render(
172+
<QueryClientProvider client={queryClient}>
173+
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
174+
<button type='button' onClick={(event) => event.preventDefault()}>
175+
Chat options
176+
</button>
177+
</ChatNavigationLink>
178+
</QueryClientProvider>
179+
)
180+
})
181+
const button = container.querySelector('button')
182+
if (!button) throw new Error('chat action not rendered')
183+
184+
act(() => {
185+
button.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))
186+
button.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))
187+
button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
188+
})
189+
190+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
191+
expect(prefetchQuery).not.toHaveBeenCalled()
192+
})
193+
135194
it('does not prefetch the chat that is already open', () => {
136195
const link = renderLink('chat-1', true)
137196

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
'use client'
22

3-
import { type ComponentProps, useCallback, useEffect, useRef, useState } from 'react'
3+
import {
4+
type ComponentProps,
5+
type PointerEvent as ReactPointerEvent,
6+
useCallback,
7+
useEffect,
8+
useRef,
9+
useState,
10+
} from 'react'
411
import { useQueryClient } from '@tanstack/react-query'
512
import Link from 'next/link'
613
import { mothershipChatHistoryQueryOptions } from '@/hooks/queries/mothership-chats'
714

815
const CHAT_PREFETCH_DWELL_MS = 80
916

17+
function isUnmodifiedPrimaryPointer(event: ReactPointerEvent<HTMLAnchorElement>) {
18+
const nestedAction =
19+
event.target instanceof Element && event.target.closest('button, [role="button"]') !== null
20+
21+
return (
22+
!event.defaultPrevented &&
23+
!nestedAction &&
24+
event.button === 0 &&
25+
!event.metaKey &&
26+
!event.ctrlKey &&
27+
!event.shiftKey &&
28+
!event.altKey
29+
)
30+
}
31+
1032
interface ChatNavigationLinkProps extends Omit<ComponentProps<typeof Link>, 'href' | 'prefetch'> {
1133
chatId: string
1234
href: string
@@ -22,6 +44,9 @@ export function ChatNavigationLink({
2244
onFocus,
2345
onMouseEnter,
2446
onMouseLeave,
47+
onPointerCancel,
48+
onPointerDown,
49+
onPointerUp,
2550
onTouchStart,
2651
...props
2752
}: ChatNavigationLinkProps) {
@@ -81,6 +106,23 @@ export function ChatNavigationLink({
81106
cancelScheduledPrefetch()
82107
setShouldPrefetchRoute(false)
83108
}}
109+
onPointerDown={(event) => {
110+
onPointerDown?.(event)
111+
if (event.pointerType === 'mouse' && isUnmodifiedPrimaryPointer(event)) {
112+
prefetchForIntent()
113+
}
114+
}}
115+
onPointerUp={(event) => {
116+
onPointerUp?.(event)
117+
if (event.pointerType !== 'mouse' && isUnmodifiedPrimaryPointer(event)) {
118+
prefetchForIntent()
119+
}
120+
}}
121+
onPointerCancel={(event) => {
122+
onPointerCancel?.(event)
123+
cancelScheduledPrefetch()
124+
setShouldPrefetchRoute(false)
125+
}}
84126
onTouchStart={onTouchStart}
85127
onClick={(event) => {
86128
onClick?.(event)

0 commit comments

Comments
 (0)