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..26a39c2b3d5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.test.tsx
@@ -0,0 +1,293 @@
+/**
+ * @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
+ }
+
+ 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()
+
+ 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('prefetches before direct mouse clicks and completed touch taps', () => {
+ const link = renderLink()
+ linkPrefetch.mockClear()
+
+ 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 }))
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+
+ 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)
+
+ act(() => link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
+
+ expect(linkPrefetch).toHaveBeenLastCalledWith(false)
+ 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()
+
+ 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..3b676835670
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx
@@ -0,0 +1,152 @@
+'use client'
+
+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
+ isCurrentRoute?: boolean
+}
+
+export function ChatNavigationLink(props: ChatNavigationLinkProps) {
+ const routeRole = props.isCurrentRoute ? 'current' : 'destination'
+ return
+}
+
+function IntentAwareChatNavigationLink({
+ chatId,
+ href,
+ isCurrentRoute = false,
+ onBlur,
+ onClick,
+ onFocus,
+ onMouseEnter,
+ onMouseLeave,
+ onPointerCancel,
+ onPointerDown,
+ onPointerUp,
+ onTouchStart,
+ ...props
+}: ChatNavigationLinkProps) {
+ const queryClient = useQueryClient()
+ const prefetchTimerRef = useRef | null>(null)
+ const navigationIntentRef = useRef(false)
+ 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
+ navigationIntentRef.current = true
+ 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()
+ navigationIntentRef.current = false
+ setShouldPrefetchRoute(false)
+ }}
+ onFocus={(event) => {
+ onFocus?.(event)
+ if (!event.defaultPrevented) prefetchForIntent()
+ }}
+ onBlur={(event) => {
+ onBlur?.(event)
+ cancelScheduledPrefetch()
+ navigationIntentRef.current = false
+ 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()
+ navigationIntentRef.current = false
+ setShouldPrefetchRoute(false)
+ }}
+ onTouchStart={onTouchStart}
+ onClick={(event) => {
+ onClick?.(event)
+ if (
+ !event.defaultPrevented &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.shiftKey &&
+ !event.altKey
+ ) {
+ cancelScheduledPrefetch()
+ if (!isCurrentRoute && !navigationIntentRef.current) prefetchHistory()
+ }
+ }}
+ />
+ )
+}
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 },
})
}