From 6738e2c8eee43f8f9a330ceb80df691a8c6513f8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 04:37:33 -0700 Subject: [PATCH 1/3] improvement(chat): speed up conversation navigation --- .claude/rules/sim-react-performance.md | 13 ++ .claude/rules/sim-url-state.md | 5 + .../[workspaceId]/chat/[chatId]/loading.tsx | 19 -- .../app/workspace/[workspaceId]/home/home.tsx | 7 +- .../[workspaceId]/home/hooks/use-chat.ts | 5 +- .../chat-navigation-link.test.tsx | 187 ++++++++++++++++++ .../chat-navigation-link.tsx | 101 ++++++++++ .../collapsed-sidebar-menu.tsx | 7 +- .../w/components/sidebar/components/index.ts | 1 + .../w/components/sidebar/sidebar.tsx | 8 +- .../hooks/queries/mothership-chats.test.ts | 29 +++ apps/sim/hooks/queries/mothership-chats.ts | 25 +-- 12 files changed, 366 insertions(+), 41 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index e64c8544ee0..2d77b324f24 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams]) 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). +## Prefetch dynamic destination lists on intent + +For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume +`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate +`` behind deliberate hover or keyboard focus, and prefetch destination +server state with the consumer's shared React Query options. A short, cancelable hover dwell +avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling; +let the actual unmodified click start the data request. + +If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains +mounted until its peer is ready, the intent path must warm both the full route and its critical +data. Otherwise keep the loading boundary so dynamic navigation remains responsive. + ## Local feature barrels are the convention — do not "fix" them 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. diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 9312fe72c9b..8a859e8a547 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`. +The narrow exception is a continuity-focused peer switch that deliberately keeps the current +view mounted and follows the full-route plus critical-data intent-prefetch rule in +`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the +route-level `loading.tsx` that would replace the current peer before the destination is ready. + This applies to **page entries**. An inner `` 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". ## Debounced text inputs diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx deleted file mode 100644 index ff1e96e036e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' - -/** - * Route-level loading boundary for a chat. - * - * Its real job is prefetching, not painting. With `cacheComponents` off, a - * default `` prefetch degrades to Next's LoadingBoundary strategy, which - * prefetches a dynamic route only as far as its nearest `loading` segment — so - * a route without one is prefetched as nothing, and clicking a chat leaves the - * previous chat frozen on screen until the server responds. This file is what - * makes that click commit immediately. - * - * Renders the same surface `HomeFallback` gives the Suspense boundary inside - * the page, so the loading frame and the mounted frame share a background and - * the transition reads as one step rather than two. - */ -export default function ChatLoading() { - return -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index ddc31f59d36..aa6bb1e3216 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -38,10 +38,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' -import { - useMarkMothershipChatRead, - useMothershipChatHistory, -} from '@/hooks/queries/mothership-chats' +import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -205,7 +202,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const wasSendingRef = useRef(false) - const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId) const { mutate: markRead } = useMarkMothershipChatRead(workspaceId) const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) @@ -242,6 +238,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const { messages, + isChatHistoryPending, isSending, isReconnecting, sendMessage, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index b662036f2ce..1ac5603f7a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -193,6 +193,7 @@ interface WithdrawnSend { export interface UseChatReturn { messages: ChatMessage[] + isChatHistoryPending: boolean isSending: boolean isReconnecting: boolean error: string | null @@ -1790,7 +1791,8 @@ export function useChat( [flushPendingResources, queryClient, workspaceId] ) - const { data: chatHistory } = useMothershipChatHistory(resolvedChatId) + const { data: chatHistory, isPending: isChatHistoryPending } = + useMothershipChatHistory(resolvedChatId) const messages = useMemo(() => { const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current)) @@ -5210,6 +5212,7 @@ export function useChat( return { messages, + isChatHistoryPending, isSending, isReconnecting, error, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx new file mode 100644 index 00000000000..8d0ddd79080 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx @@ -0,0 +1,187 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { linkPrefetch } = vi.hoisted(() => ({ + linkPrefetch: vi.fn(), +})) + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + prefetch, + ...props + }: { + href: string + children: React.ReactNode + prefetch?: boolean + }) => { + linkPrefetch(prefetch) + return ( + + {children} + + ) + }, +})) + +import { ChatNavigationLink } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link' + +describe('ChatNavigationLink', () => { + let container: HTMLDivElement + let queryClient: QueryClient + let root: Root + let prefetchQuery: ReturnType + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + linkPrefetch.mockReset() + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + queryClient.clear() + vi.useRealTimers() + }) + + function renderLink(chatId = 'chat-1', isCurrentRoute = false) { + act(() => { + root.render( + + + Open chat + + + ) + }) + const link = container.querySelector('a') + if (!link) throw new Error('chat link not rendered') + return link + } + + it('prefetches the route and exact history after deliberate pointer intent', () => { + const link = renderLink() + + act(() => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + vi.advanceTimersByTime(79) + }) + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + + act(() => vi.advanceTimersByTime(1)) + + expect(linkPrefetch).toHaveBeenLastCalledWith(true) + expect(prefetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['mothership-chats', 'detail', 'chat-1'], + staleTime: 30_000, + }) + ) + + act(() => link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).toHaveBeenCalledTimes(1) + }) + + it('cancels drive-by hover prefetches', () => { + const link = renderLink() + + act(() => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) + vi.runAllTimers() + }) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + + it('prefetches immediately for keyboard focus without fetching a new-chat history', () => { + const link = renderLink('new') + + act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(true) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + + it('does not treat touch scrolling as navigation intent', () => { + const link = renderLink() + + act(() => link.dispatchEvent(new TouchEvent('touchstart', { bubbles: true }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + + it('does not prefetch the chat that is already open', () => { + const link = renderLink('chat-1', true) + + act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + + it('does not prefetch when the click is canceled or opens another browsing context', () => { + const canceledLink = renderLink() + + act(() => { + root.render( + + event.preventDefault()} + > + Open chat + + + ) + }) + act(() => { + canceledLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + + act(() => { + root.render( + + + Open chat + + + ) + }) + const modifiedLink = container.querySelector('a') + if (!modifiedLink) throw new Error('chat link not rendered') + act(() => { + modifiedLink.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true }) + ) + }) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx new file mode 100644 index 00000000000..7455279c3aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx @@ -0,0 +1,101 @@ +'use client' + +import { type ComponentProps, useCallback, useEffect, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import Link from 'next/link' +import { mothershipChatHistoryQueryOptions } from '@/hooks/queries/mothership-chats' + +const CHAT_PREFETCH_DWELL_MS = 80 + +interface ChatNavigationLinkProps extends Omit, 'href' | 'prefetch'> { + chatId: string + href: string + isCurrentRoute?: boolean +} + +export function ChatNavigationLink({ + chatId, + href, + isCurrentRoute = false, + onBlur, + onClick, + onFocus, + onMouseEnter, + onMouseLeave, + onTouchStart, + ...props +}: ChatNavigationLinkProps) { + const queryClient = useQueryClient() + const prefetchTimerRef = useRef | null>(null) + const [shouldPrefetchRoute, setShouldPrefetchRoute] = useState(false) + + const cancelScheduledPrefetch = useCallback(() => { + if (prefetchTimerRef.current === null) return + clearTimeout(prefetchTimerRef.current) + prefetchTimerRef.current = null + }, []) + + const prefetchHistory = () => { + if (chatId !== 'new') { + void queryClient.prefetchQuery(mothershipChatHistoryQueryOptions(chatId)) + } + } + + const prefetchForIntent = () => { + cancelScheduledPrefetch() + if (isCurrentRoute) return + setShouldPrefetchRoute(true) + prefetchHistory() + } + + const schedulePrefetch = () => { + cancelScheduledPrefetch() + prefetchTimerRef.current = setTimeout(() => { + prefetchTimerRef.current = null + prefetchForIntent() + }, CHAT_PREFETCH_DWELL_MS) + } + + useEffect(() => cancelScheduledPrefetch, [cancelScheduledPrefetch]) + + return ( + { + onMouseEnter?.(event) + if (!event.defaultPrevented) schedulePrefetch() + }} + onMouseLeave={(event) => { + onMouseLeave?.(event) + cancelScheduledPrefetch() + setShouldPrefetchRoute(false) + }} + onFocus={(event) => { + onFocus?.(event) + if (!event.defaultPrevented) prefetchForIntent() + }} + onBlur={(event) => { + onBlur?.(event) + cancelScheduledPrefetch() + setShouldPrefetchRoute(false) + }} + onTouchStart={onTouchStart} + onClick={(event) => { + onClick?.(event) + if ( + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + cancelScheduledPrefetch() + if (!isCurrentRoute && !shouldPrefetchRoute) prefetchHistory() + setShouldPrefetchRoute(false) + } + }} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index f48a6c1eb81..ae2ce8f6fa0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -18,6 +18,7 @@ import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@ import Link from 'next/link' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders' +import { ChatNavigationLink } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link' import { SidebarNavChip, type SidebarNavItemData, @@ -341,8 +342,10 @@ export function CollapsedChatFlyoutItem({ ) : undefined } > - onContextMenu(e, chat.id) : undefined } @@ -352,7 +355,7 @@ export function CollapsedChatFlyoutItem({ isActive={!!chat.isActive} isUnread={!!chat.isUnread && !isCurrentRoute} /> - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index 69b503f2cc4..6ad98b4755c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -1,3 +1,4 @@ +export { ChatNavigationLink } from './chat-navigation-link/chat-navigation-link' export { CollapsedChatFlyoutItem, CollapsedFolderItems, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index d8714768c99..4d06bf87c44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -32,7 +32,6 @@ import { Workflow, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' @@ -49,6 +48,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + ChatNavigationLink, CollapsedChatFlyoutItem, CollapsedFolderItems, CollapsedSidebarMenu, @@ -253,8 +253,10 @@ const SidebarChatItem = memo(function SidebarChatItem({ return ( - )} - + ) }) diff --git a/apps/sim/hooks/queries/mothership-chats.test.ts b/apps/sim/hooks/queries/mothership-chats.test.ts index 520fc5ac8ca..755367b1040 100644 --- a/apps/sim/hooks/queries/mothership-chats.test.ts +++ b/apps/sim/hooks/queries/mothership-chats.test.ts @@ -19,6 +19,8 @@ const { queryClient, suspendBrowserScope, suspendTerminalScope } = vi.hoisted(() vi.mock('@tanstack/react-query', () => ({ keepPreviousData: {}, + queryOptions: (options: unknown) => options, + skipToken: Symbol('skipToken'), useQuery: vi.fn(), useQueryClient: vi.fn(() => queryClient), useMutation: vi.fn((options) => options), @@ -38,6 +40,7 @@ import { useAddChatResource, useDeleteMothershipChat, useDeleteMothershipChats, + useMarkMothershipChatRead, } from '@/hooks/queries/mothership-chats' function jsonResponse(body: unknown, init?: ResponseInit): Response { @@ -172,6 +175,32 @@ describe('tasks query boundary parsing', () => { ) }) + it('does not call the legacy alias when the primary history request fails outside 404', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + jsonResponse({ success: false, error: 'Unavailable' }, { status: 503 }) + ) + + await expect(fetchMothershipChatHistory('chat-1')).rejects.toMatchObject({ status: 503 }) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('uses the conditional read endpoint when marking a chat as seen', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ success: true })) + const mutation = useMarkMothershipChatRead('ws-1') as unknown as { + mutationFn: (chatId: string) => Promise + } + + await mutation.mutationFn('chat-1') + + expect(fetch).toHaveBeenCalledWith( + '/api/mothership/chats/read', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ chatId: 'chat-1' }), + }) + ) + }) + it('rejects invalid chat resource mutation responses', async () => { vi.mocked(fetch).mockResolvedValueOnce( jsonResponse({ diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index b2dad6357c0..0c05de3ceb0 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -1,6 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import { keepPreviousData, + queryOptions, skipToken, useMutation, useQuery, @@ -16,6 +17,7 @@ import { listMothershipChatsContract, type MothershipChat, type MothershipChatScope, + markMothershipChatReadContract, removeMothershipChatResourceContract, reorderMothershipChatResourcesContract, restoreMothershipChatContract, @@ -252,9 +254,7 @@ export async function fetchMothershipChatHistory( }) return parseChatHistory(data) } catch (error) { - if (!isApiClientError(error)) throw error - // Fall through to the legacy copilot-shape alias on any HTTP error (typically 404 - // when the chat lives in the older copilot table and isn't a mothership-typed row). + if (!isApiClientError(error) || error.status !== 404) throw error } // boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the @@ -271,16 +271,20 @@ export async function fetchMothershipChatHistory( return parseChatHistory(await copilotRes.json()) } +export function mothershipChatHistoryQueryOptions(chatId: string | undefined) { + return queryOptions({ + queryKey: mothershipChatKeys.detail(chatId), + queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken, + staleTime: MOTHERSHIP_CHAT_HISTORY_STALE_TIME, + }) +} + /** * Fetches chat history for a single chat (mothership chat). * Used by the chat page to load an existing conversation. */ export function useMothershipChatHistory(chatId: string | undefined) { - return useQuery({ - queryKey: mothershipChatKeys.detail(chatId), - queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken, - staleTime: MOTHERSHIP_CHAT_HISTORY_STALE_TIME, - }) + return useQuery(mothershipChatHistoryQueryOptions(chatId)) } async function deleteChat(chatId: string): Promise { @@ -530,9 +534,8 @@ export function useRemoveChatResource(chatId?: string) { } async function markChatRead(chatId: string): Promise { - await requestJson(updateMothershipChatContract, { - params: { chatId }, - body: { isUnread: false }, + await requestJson(markMothershipChatReadContract, { + body: { chatId }, }) } From 51fff882840aaba6657135465b0a6d19ac55e363 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 04:50:07 -0700 Subject: [PATCH 2/3] fix(chat): prefetch direct navigation intent --- .../chat-navigation-link.test.tsx | 59 +++++++++++++++++++ .../chat-navigation-link.tsx | 44 +++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx index 8d0ddd79080..8cf4f9b201c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx @@ -75,6 +75,12 @@ describe('ChatNavigationLink', () => { return link } + function pointerEvent(type: string, pointerType: 'mouse' | 'touch', init?: MouseEventInit) { + const event = new MouseEvent(type, { bubbles: true, ...init }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + return event + } + it('prefetches the route and exact history after deliberate pointer intent', () => { const link = renderLink() @@ -132,6 +138,59 @@ describe('ChatNavigationLink', () => { expect(prefetchQuery).not.toHaveBeenCalled() }) + it('prefetches before direct mouse clicks and completed touch taps', () => { + const link = renderLink() + + act(() => link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(true) + expect(prefetchQuery).toHaveBeenCalledTimes(1) + + act(() => link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + prefetchQuery.mockClear() + act(() => link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))) + + expect(linkPrefetch).toHaveBeenLastCalledWith(true) + expect(prefetchQuery).toHaveBeenCalledTimes(1) + }) + + it('cancels touch-scroll pointer intent before it can prefetch', () => { + const link = renderLink() + + act(() => { + link.dispatchEvent(pointerEvent('pointerdown', 'touch', { button: 0 })) + link.dispatchEvent(pointerEvent('pointercancel', 'touch', { button: 0 })) + }) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + + it('does not prefetch when a nested chat action is pressed', () => { + act(() => { + root.render( + + + + + + ) + }) + const button = container.querySelector('button') + if (!button) throw new Error('chat action not rendered') + + act(() => { + button.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 })) + button.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 })) + button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + expect(prefetchQuery).not.toHaveBeenCalled() + }) + it('does not prefetch the chat that is already open', () => { const link = renderLink('chat-1', true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx index 7455279c3aa..9ff140db6e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx @@ -1,12 +1,34 @@ 'use client' -import { type ComponentProps, useCallback, useEffect, useRef, useState } from 'react' +import { + type ComponentProps, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import { mothershipChatHistoryQueryOptions } from '@/hooks/queries/mothership-chats' const CHAT_PREFETCH_DWELL_MS = 80 +function isUnmodifiedPrimaryPointer(event: ReactPointerEvent) { + const nestedAction = + event.target instanceof Element && event.target.closest('button, [role="button"]') !== null + + return ( + !event.defaultPrevented && + !nestedAction && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) +} + interface ChatNavigationLinkProps extends Omit, 'href' | 'prefetch'> { chatId: string href: string @@ -22,6 +44,9 @@ export function ChatNavigationLink({ onFocus, onMouseEnter, onMouseLeave, + onPointerCancel, + onPointerDown, + onPointerUp, onTouchStart, ...props }: ChatNavigationLinkProps) { @@ -81,6 +106,23 @@ export function ChatNavigationLink({ cancelScheduledPrefetch() setShouldPrefetchRoute(false) }} + onPointerDown={(event) => { + onPointerDown?.(event) + if (event.pointerType === 'mouse' && isUnmodifiedPrimaryPointer(event)) { + prefetchForIntent() + } + }} + onPointerUp={(event) => { + onPointerUp?.(event) + if (event.pointerType !== 'mouse' && isUnmodifiedPrimaryPointer(event)) { + prefetchForIntent() + } + }} + onPointerCancel={(event) => { + onPointerCancel?.(event) + cancelScheduledPrefetch() + setShouldPrefetchRoute(false) + }} onTouchStart={onTouchStart} onClick={(event) => { onClick?.(event) From b7e2669de212fa9ae24385d01a59e5777233dde1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 10:36:11 -0700 Subject: [PATCH 3/3] fix(chat): preserve quick-click prefetch intent --- .../chat-navigation-link.test.tsx | 51 ++++++++++++++++++- .../chat-navigation-link.tsx | 15 ++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx index 8cf4f9b201c..26a39c2b3d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx @@ -140,15 +140,23 @@ describe('ChatNavigationLink', () => { it('prefetches before direct mouse clicks and completed touch taps', () => { const link = renderLink() + linkPrefetch.mockClear() - act(() => link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))) + act(() => { + link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 })) + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) expect(linkPrefetch).toHaveBeenLastCalledWith(true) expect(prefetchQuery).toHaveBeenCalledTimes(1) act(() => link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + linkPrefetch.mockClear() prefetchQuery.mockClear() - act(() => link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))) + act(() => { + link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 })) + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) expect(linkPrefetch).toHaveBeenLastCalledWith(true) expect(prefetchQuery).toHaveBeenCalledTimes(1) @@ -200,6 +208,45 @@ describe('ChatNavigationLink', () => { expect(prefetchQuery).not.toHaveBeenCalled() }) + it('clears prior intent when a persistent row changes route roles', () => { + const renderRouteRole = (isCurrentRoute: boolean) => { + act(() => { + root.render( + + + Open chat + + + ) + }) + } + + renderRouteRole(false) + const link = container.querySelector('a') + if (!link) throw new Error('chat link not rendered') + act(() => { + link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 })) + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + expect(linkPrefetch).toHaveBeenLastCalledWith(true) + + renderRouteRole(true) + renderRouteRole(false) + + expect(linkPrefetch).toHaveBeenLastCalledWith(false) + prefetchQuery.mockClear() + const destinationLink = container.querySelector('a') + if (!destinationLink) throw new Error('destination link not rendered') + act(() => { + destinationLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + expect(prefetchQuery).toHaveBeenCalledTimes(1) + }) + it('does not prefetch when the click is canceled or opens another browsing context', () => { const canceledLink = renderLink() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx index 9ff140db6e3..3b676835670 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx @@ -35,7 +35,12 @@ interface ChatNavigationLinkProps extends Omit, 'hre isCurrentRoute?: boolean } -export function ChatNavigationLink({ +export function ChatNavigationLink(props: ChatNavigationLinkProps) { + const routeRole = props.isCurrentRoute ? 'current' : 'destination' + return +} + +function IntentAwareChatNavigationLink({ chatId, href, isCurrentRoute = false, @@ -52,6 +57,7 @@ export function ChatNavigationLink({ }: ChatNavigationLinkProps) { const queryClient = useQueryClient() const prefetchTimerRef = useRef | null>(null) + const navigationIntentRef = useRef(false) const [shouldPrefetchRoute, setShouldPrefetchRoute] = useState(false) const cancelScheduledPrefetch = useCallback(() => { @@ -69,6 +75,7 @@ export function ChatNavigationLink({ const prefetchForIntent = () => { cancelScheduledPrefetch() if (isCurrentRoute) return + navigationIntentRef.current = true setShouldPrefetchRoute(true) prefetchHistory() } @@ -95,6 +102,7 @@ export function ChatNavigationLink({ onMouseLeave={(event) => { onMouseLeave?.(event) cancelScheduledPrefetch() + navigationIntentRef.current = false setShouldPrefetchRoute(false) }} onFocus={(event) => { @@ -104,6 +112,7 @@ export function ChatNavigationLink({ onBlur={(event) => { onBlur?.(event) cancelScheduledPrefetch() + navigationIntentRef.current = false setShouldPrefetchRoute(false) }} onPointerDown={(event) => { @@ -121,6 +130,7 @@ export function ChatNavigationLink({ onPointerCancel={(event) => { onPointerCancel?.(event) cancelScheduledPrefetch() + navigationIntentRef.current = false setShouldPrefetchRoute(false) }} onTouchStart={onTouchStart} @@ -134,8 +144,7 @@ export function ChatNavigationLink({ !event.altKey ) { cancelScheduledPrefetch() - if (!isCurrentRoute && !shouldPrefetchRoute) prefetchHistory() - setShouldPrefetchRoute(false) + if (!isCurrentRoute && !navigationIntentRef.current) prefetchHistory() } }} />