Skip to content

Commit fc8368b

Browse files
committed
improvement(chat): speed up conversation navigation
1 parent 465bdbd commit fc8368b

12 files changed

Lines changed: 366 additions & 41 deletions

File tree

.claude/rules/sim-react-performance.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])
9090

9191
Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).
9292

93+
## Prefetch dynamic destination lists on intent
94+
95+
For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
96+
`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
97+
`<Link prefetch={true}>` behind deliberate hover or keyboard focus, and prefetch destination
98+
server state with the consumer's shared React Query options. A short, cancelable hover dwell
99+
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
100+
let the actual unmodified click start the data request.
101+
102+
If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
103+
mounted until its peer is ready, the intent path must warm both the full route and its critical
104+
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.
105+
93106
## Local feature barrels are the convention — do not "fix" them
94107

95108
Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them.

.claude/rules/sim-url-state.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l
143143

144144
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
145145

146+
The narrow exception is a continuity-focused peer switch that deliberately keeps the current
147+
view mounted and follows the full-route plus critical-data intent-prefetch rule in
148+
`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the
149+
route-level `loading.tsx` that would replace the current peer before the destination is ready.
150+
146151
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
147152

148153
## Debounced text inputs

apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx

Lines changed: 0 additions & 19 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp
3838
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
3939
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
4040
import { useFolders } from '@/hooks/queries/folders'
41-
import {
42-
useMarkMothershipChatRead,
43-
useMothershipChatHistory,
44-
} from '@/hooks/queries/mothership-chats'
41+
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
4542
import { useWorkflows } from '@/hooks/queries/workflows'
4643
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4744
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
@@ -205,7 +202,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
205202

206203
const wasSendingRef = useRef(false)
207204

208-
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
209205
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
210206

211207
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
@@ -242,6 +238,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
242238

243239
const {
244240
messages,
241+
isChatHistoryPending,
245242
isSending,
246243
isReconnecting,
247244
sendMessage,

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ interface WithdrawnSend {
193193

194194
export interface UseChatReturn {
195195
messages: ChatMessage[]
196+
isChatHistoryPending: boolean
196197
isSending: boolean
197198
isReconnecting: boolean
198199
error: string | null
@@ -1790,7 +1791,8 @@ export function useChat(
17901791
[flushPendingResources, queryClient, workspaceId]
17911792
)
17921793

1793-
const { data: chatHistory } = useMothershipChatHistory(resolvedChatId)
1794+
const { data: chatHistory, isPending: isChatHistoryPending } =
1795+
useMothershipChatHistory(resolvedChatId)
17941796
const messages = useMemo(() => {
17951797
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
17961798
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
@@ -5210,6 +5212,7 @@ export function useChat(
52105212

52115213
return {
52125214
messages,
5215+
isChatHistoryPending,
52135216
isSending,
52145217
isReconnecting,
52155218
error,
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { linkPrefetch } = vi.hoisted(() => ({
10+
linkPrefetch: vi.fn(),
11+
}))
12+
13+
vi.mock('next/link', () => ({
14+
default: ({
15+
href,
16+
children,
17+
prefetch,
18+
...props
19+
}: {
20+
href: string
21+
children: React.ReactNode
22+
prefetch?: boolean
23+
}) => {
24+
linkPrefetch(prefetch)
25+
return (
26+
<a href={href} {...props}>
27+
{children}
28+
</a>
29+
)
30+
},
31+
}))
32+
33+
import { ChatNavigationLink } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link'
34+
35+
describe('ChatNavigationLink', () => {
36+
let container: HTMLDivElement
37+
let queryClient: QueryClient
38+
let root: Root
39+
let prefetchQuery: ReturnType<typeof vi.spyOn>
40+
41+
beforeEach(() => {
42+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
43+
vi.useFakeTimers()
44+
linkPrefetch.mockReset()
45+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
46+
prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue()
47+
container = document.createElement('div')
48+
document.body.appendChild(container)
49+
root = createRoot(container)
50+
})
51+
52+
afterEach(() => {
53+
act(() => root.unmount())
54+
container.remove()
55+
queryClient.clear()
56+
vi.useRealTimers()
57+
})
58+
59+
function renderLink(chatId = 'chat-1', isCurrentRoute = false) {
60+
act(() => {
61+
root.render(
62+
<QueryClientProvider client={queryClient}>
63+
<ChatNavigationLink
64+
chatId={chatId}
65+
href={`/workspace/ws-1/chat/${chatId}`}
66+
isCurrentRoute={isCurrentRoute}
67+
>
68+
Open chat
69+
</ChatNavigationLink>
70+
</QueryClientProvider>
71+
)
72+
})
73+
const link = container.querySelector('a')
74+
if (!link) throw new Error('chat link not rendered')
75+
return link
76+
}
77+
78+
it('prefetches the route and exact history after deliberate pointer intent', () => {
79+
const link = renderLink()
80+
81+
act(() => {
82+
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
83+
vi.advanceTimersByTime(79)
84+
})
85+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
86+
expect(prefetchQuery).not.toHaveBeenCalled()
87+
88+
act(() => vi.advanceTimersByTime(1))
89+
90+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
91+
expect(prefetchQuery).toHaveBeenCalledWith(
92+
expect.objectContaining({
93+
queryKey: ['mothership-chats', 'detail', 'chat-1'],
94+
staleTime: 30_000,
95+
})
96+
)
97+
98+
act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })))
99+
100+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
101+
expect(prefetchQuery).toHaveBeenCalledTimes(1)
102+
})
103+
104+
it('cancels drive-by hover prefetches', () => {
105+
const link = renderLink()
106+
107+
act(() => {
108+
link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
109+
link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
110+
vi.runAllTimers()
111+
})
112+
113+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
114+
expect(prefetchQuery).not.toHaveBeenCalled()
115+
})
116+
117+
it('prefetches immediately for keyboard focus without fetching a new-chat history', () => {
118+
const link = renderLink('new')
119+
120+
act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
121+
122+
expect(linkPrefetch).toHaveBeenLastCalledWith(true)
123+
expect(prefetchQuery).not.toHaveBeenCalled()
124+
})
125+
126+
it('does not treat touch scrolling as navigation intent', () => {
127+
const link = renderLink()
128+
129+
act(() => link.dispatchEvent(new TouchEvent('touchstart', { bubbles: true })))
130+
131+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
132+
expect(prefetchQuery).not.toHaveBeenCalled()
133+
})
134+
135+
it('does not prefetch the chat that is already open', () => {
136+
const link = renderLink('chat-1', true)
137+
138+
act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
139+
140+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
141+
expect(prefetchQuery).not.toHaveBeenCalled()
142+
})
143+
144+
it('does not prefetch when the click is canceled or opens another browsing context', () => {
145+
const canceledLink = renderLink()
146+
147+
act(() => {
148+
root.render(
149+
<QueryClientProvider client={queryClient}>
150+
<ChatNavigationLink
151+
chatId='chat-1'
152+
href='/workspace/ws-1/chat/chat-1'
153+
onClick={(event) => event.preventDefault()}
154+
>
155+
Open chat
156+
</ChatNavigationLink>
157+
</QueryClientProvider>
158+
)
159+
})
160+
act(() => {
161+
canceledLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
162+
})
163+
164+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
165+
expect(prefetchQuery).not.toHaveBeenCalled()
166+
167+
act(() => {
168+
root.render(
169+
<QueryClientProvider client={queryClient}>
170+
<ChatNavigationLink chatId='chat-1' href='/workspace/ws-1/chat/chat-1'>
171+
Open chat
172+
</ChatNavigationLink>
173+
</QueryClientProvider>
174+
)
175+
})
176+
const modifiedLink = container.querySelector('a')
177+
if (!modifiedLink) throw new Error('chat link not rendered')
178+
act(() => {
179+
modifiedLink.dispatchEvent(
180+
new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true })
181+
)
182+
})
183+
184+
expect(linkPrefetch).toHaveBeenLastCalledWith(false)
185+
expect(prefetchQuery).not.toHaveBeenCalled()
186+
})
187+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use client'
2+
3+
import { type ComponentProps, useCallback, useEffect, useRef, useState } from 'react'
4+
import { useQueryClient } from '@tanstack/react-query'
5+
import Link from 'next/link'
6+
import { mothershipChatHistoryQueryOptions } from '@/hooks/queries/mothership-chats'
7+
8+
const CHAT_PREFETCH_DWELL_MS = 80
9+
10+
interface ChatNavigationLinkProps extends Omit<ComponentProps<typeof Link>, 'href' | 'prefetch'> {
11+
chatId: string
12+
href: string
13+
isCurrentRoute?: boolean
14+
}
15+
16+
export function ChatNavigationLink({
17+
chatId,
18+
href,
19+
isCurrentRoute = false,
20+
onBlur,
21+
onClick,
22+
onFocus,
23+
onMouseEnter,
24+
onMouseLeave,
25+
onTouchStart,
26+
...props
27+
}: ChatNavigationLinkProps) {
28+
const queryClient = useQueryClient()
29+
const prefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
30+
const [shouldPrefetchRoute, setShouldPrefetchRoute] = useState(false)
31+
32+
const cancelScheduledPrefetch = useCallback(() => {
33+
if (prefetchTimerRef.current === null) return
34+
clearTimeout(prefetchTimerRef.current)
35+
prefetchTimerRef.current = null
36+
}, [])
37+
38+
const prefetchHistory = () => {
39+
if (chatId !== 'new') {
40+
void queryClient.prefetchQuery(mothershipChatHistoryQueryOptions(chatId))
41+
}
42+
}
43+
44+
const prefetchForIntent = () => {
45+
cancelScheduledPrefetch()
46+
if (isCurrentRoute) return
47+
setShouldPrefetchRoute(true)
48+
prefetchHistory()
49+
}
50+
51+
const schedulePrefetch = () => {
52+
cancelScheduledPrefetch()
53+
prefetchTimerRef.current = setTimeout(() => {
54+
prefetchTimerRef.current = null
55+
prefetchForIntent()
56+
}, CHAT_PREFETCH_DWELL_MS)
57+
}
58+
59+
useEffect(() => cancelScheduledPrefetch, [cancelScheduledPrefetch])
60+
61+
return (
62+
<Link
63+
{...props}
64+
href={href}
65+
prefetch={!isCurrentRoute && shouldPrefetchRoute}
66+
onMouseEnter={(event) => {
67+
onMouseEnter?.(event)
68+
if (!event.defaultPrevented) schedulePrefetch()
69+
}}
70+
onMouseLeave={(event) => {
71+
onMouseLeave?.(event)
72+
cancelScheduledPrefetch()
73+
setShouldPrefetchRoute(false)
74+
}}
75+
onFocus={(event) => {
76+
onFocus?.(event)
77+
if (!event.defaultPrevented) prefetchForIntent()
78+
}}
79+
onBlur={(event) => {
80+
onBlur?.(event)
81+
cancelScheduledPrefetch()
82+
setShouldPrefetchRoute(false)
83+
}}
84+
onTouchStart={onTouchStart}
85+
onClick={(event) => {
86+
onClick?.(event)
87+
if (
88+
!event.defaultPrevented &&
89+
!event.metaKey &&
90+
!event.ctrlKey &&
91+
!event.shiftKey &&
92+
!event.altKey
93+
) {
94+
cancelScheduledPrefetch()
95+
if (!isCurrentRoute && !shouldPrefetchRoute) prefetchHistory()
96+
setShouldPrefetchRoute(false)
97+
}
98+
}}
99+
/>
100+
)
101+
}

0 commit comments

Comments
 (0)