diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
index 2c56d1d9837..999260b02e7 100644
--- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
@@ -1,8 +1,13 @@
import { Suspense } from 'react'
+import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
+import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
+import { isChatEnabled } from '@/lib/core/config/env-flags'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
+import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
export const metadata: Metadata = {
@@ -17,18 +22,30 @@ interface ChatPageProps {
}
export default async function ChatPage({ params }: ChatPageProps) {
+ // The layout 404s too, but pages and layouts resolve concurrently — without this
+ // the prefetch below still fires on its way out.
+ if (!isChatEnabled) {
+ notFound()
+ }
+
const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
const userId = session?.user?.id
- const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
+ const queryClient = getQueryClient()
+ const [tableViewsEnabled] = await Promise.all([
+ resolveTableViewsEnabled(workspaceId, userId),
+ prefetchHomeSurface(queryClient, workspaceId, userId),
+ ])
return (
- }>
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts
index 54d0276fcef..dd08f5fc925 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts
@@ -3,6 +3,7 @@ import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace-
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
+import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
import {
WORKSPACE_FILE_FOLDERS_STALE_TIME,
workspaceFileFolderKeys,
@@ -15,14 +16,8 @@ import {
* the Owner column — under the same query keys their client hooks (`useWorkspaceFileFolders`) use
* (scope `active`), so the browser paints populated on first render.
*
- * The FILE LIST itself is deliberately not here: the sidebar reads it on every workspace route, so
- * it is seeded by `prefetchWorkspaceSidebar` in the layout — the only boundary that renders
- * before the sidebar registers the query. Prefetching it again here would re-read it per request
- * and still not reach the server render (`HydrationBoundary` defers an already-seen query to an
- * effect, which SSR never runs). See the note on that entry. The layout declines to seed a
- * workspace whose file list exceeds its payload budget; recovering those here would mean
- * mirroring that budget check inversely, since an unconditional prefetch would re-read and
- * duplicate the entry for every workspace under the budget.
+ * The file list is seeded here rather than in the layout so only the routes that render it pay for
+ * it. See {@link seedWorkspaceFiles} for why a large workspace seeds nothing at all.
*
* Folders and the chrome reads all go through the data layer, shaped to their route contracts so a
* hydrated entry matches a client fetch.
@@ -58,5 +53,6 @@ export async function prefetchFilesBrowser(
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
}),
prefetchResourceListChrome(queryClient, workspaceId, 'file', userId),
+ seedWorkspaceFiles(queryClient, workspaceId),
])
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx
index cfb87f8ce04..3b54ff2d55d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx
@@ -1,8 +1,11 @@
import { Suspense } from 'react'
+import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { isChatEnabled } from '@/lib/core/config/env-flags'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
+import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
import { Home } from './home'
import { HomeFallback } from './home-fallback'
@@ -20,19 +23,23 @@ export default async function HomePage({ params }: { params: Promise<{ workspace
redirect(`/workspace/${workspaceId}`)
}
- /**
- * Home prefetches nothing of its own. Both lists it reads — workflow folders and
- * the workspace file list — are hydrated by `prefetchWorkspaceSidebar` in the
- * layout under the same keys, and re-reading them here would cost a second query
- * per request without reaching the server render.
- */
const session = await getSession()
const userId = session?.user?.id
- const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
+ const queryClient = getQueryClient()
+ const [tableViewsEnabled] = await Promise.all([
+ resolveTableViewsEnabled(workspaceId, userId),
+ prefetchHomeSurface(queryClient, workspaceId, userId),
+ ])
return (
- }>
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts
new file mode 100644
index 00000000000..b47b000b0f1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts
@@ -0,0 +1,27 @@
+import type { QueryClient } from '@tanstack/react-query'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
+
+/**
+ * Prefetches what the Home surface needs on top of the workspace layout's own prefetch.
+ *
+ * Home reads the workspace file list on mount (resource tabs, mentions, the resource picker), so
+ * the list is seeded by the routes that render Home rather than by the layout: seeding it in the
+ * layout would pay for it on every workspace route, including the ones that never read it.
+ *
+ * The seed carries no authorization of its own, so the viewer is proved first. This reuses the
+ * layout's `cache`d host-context lookup rather than re-deriving the permission, so it costs no
+ * additional queries; a viewer without access caches nothing and the client fetch reaches the
+ * route for the real 403.
+ */
+export async function prefetchHomeSurface(
+ queryClient: QueryClient,
+ workspaceId: string,
+ userId: string | undefined
+): Promise {
+ if (!userId) return
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
+ if (!hostContext) return
+
+ await seedWorkspaceFiles(queryClient, workspaceId)
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
index c27a77959b0..6bb77574bb4 100644
--- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
@@ -97,10 +97,7 @@ vi.mock('@sim/emcn', () => ({
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
-import {
- prefetchWorkspaceSidebar,
- WORKSPACE_FILE_SEED_MAX,
-} from '@/app/workspace/[workspaceId]/prefetch'
+import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
@@ -357,19 +354,18 @@ describe('workspace list prefetches', () => {
})
/**
- * The FILE LIST is deliberately not primed here — `prefetchWorkspaceSidebar` owns it, because the
- * sidebar reads that query on every workspace route and therefore registers it before any page
- * renders. `HydrationBoundary` hands an already-seen query to a `useEffect`, which SSR never runs,
- * so a page-level prefetch of this key costs a request per render and still cannot reach the server
- * render. Restoring it here would reintroduce exactly that.
+ * The file list is the browser's primary content, so it must be seeded by the page that
+ * renders it — the layout no longer seeds it, which would have charged every workspace
+ * route for a list only a few of them read.
*/
- it('leaves the file list to the layout rather than re-reading it per page', async () => {
+ it('seeds the file list the browser renders', async () => {
+ const files = [{ id: 'file-1' }]
+ mockListWorkspaceFilesWithShares.mockResolvedValue(files)
const client = makeClient()
await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID)
- expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
- expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
+ expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
})
/**
@@ -514,48 +510,15 @@ describe('workspace list prefetches', () => {
})
/**
- * The file list is seeded on every workspace route, so it is the one entry whose size
- * scales with a workspace's content on routes that never read it. The budget is passed
- * down rather than applied here, so the read can stop before the share join.
- */
- it('seeds the file list, bounded by the document payload budget', async () => {
- const files = [{ id: 'file-1', name: 'a.txt' }]
- mockListWorkspaceFilesWithShares.mockResolvedValue(files)
- const client = makeClient()
-
- await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
-
- expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
- maxRows: WORKSPACE_FILE_SEED_MAX,
- /** A failed read must reach the catch, not degrade to a cached empty list. */
- throwOnError: true,
- })
- expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
- })
-
- /**
- * The load-bearing half of the budget: a workspace over it seeds NOTHING rather than the
- * prefix that was read. The sidebar search filters this list client-side and the Files
- * browser renders it as the workspace's files, so a truncated seed would silently hide
- * files — the client fetch must reach the route for the complete list instead.
+ * The file list belongs to the pages that render it, not to every workspace route. A sidebar
+ * seed would charge the workflow editor, logs, and settings for a read none of them make.
*/
- it('seeds nothing when the workspace exceeds the budget', async () => {
- mockListWorkspaceFilesWithShares.mockResolvedValue(null)
+ it('does not read the workspace file list', async () => {
const client = makeClient()
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
- expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
- })
-
- /** A failed file read is an optimization loss, not a render failure. */
- it('does not throw when the file read rejects, and seeds no files', async () => {
- mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
- const client = makeClient()
-
- await expect(
- prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
- ).resolves.toBeUndefined()
+ expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
})
@@ -591,9 +554,9 @@ describe('workspace list prefetches', () => {
],
[
/**
- * Asserted against the folder key, not the file list: `prefetchFilesBrowser`
- * deliberately never seeds `workspaceFilesKeys` (the layout owns it), so an
- * assertion on that key would hold no matter what this function did.
+ * Asserted against the folder key: the file list is seeded rather than prefetched, so
+ * a rejecting read leaves that key empty by design and could not distinguish a
+ * swallowed failure from a function that did nothing.
*/
'prefetchFilesBrowser',
(client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID),
diff --git a/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts
new file mode 100644
index 00000000000..458113e2a1d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts
@@ -0,0 +1,80 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockListWorkspaceFilesWithShares } = vi.hoisted(() => ({
+ mockListWorkspaceFilesWithShares: vi.fn(),
+}))
+
+vi.mock('@/lib/workspace-files/queries', () => ({
+ listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares,
+}))
+
+/** The key factory lives in a `'use client'` module that pulls emcn's CSS at import. */
+vi.mock('@sim/emcn', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+import {
+ seedWorkspaceFiles,
+ WORKSPACE_FILE_SEED_MAX,
+} from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
+import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
+
+const WORKSPACE_ID = 'ws-123'
+
+function makeClient() {
+ const store = new Map()
+ return {
+ setQueryData: (key: readonly unknown[], value: unknown) =>
+ store.set(JSON.stringify(key), value),
+ getQueryData: (key: readonly unknown[]) => store.get(JSON.stringify(key)),
+ } as never as import('@tanstack/react-query').QueryClient & {
+ getQueryData: (key: readonly unknown[]) => unknown
+ }
+}
+
+describe('seedWorkspaceFiles', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('seeds the file list, bounded by the document payload budget', async () => {
+ const files = [{ id: 'file-1', name: 'a.txt' }]
+ mockListWorkspaceFilesWithShares.mockResolvedValue(files)
+ const client = makeClient()
+
+ await seedWorkspaceFiles(client, WORKSPACE_ID)
+
+ expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
+ maxRows: WORKSPACE_FILE_SEED_MAX,
+ /** A failed read must reach the catch, not degrade to a cached empty list. */
+ throwOnError: true,
+ })
+ expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
+ })
+
+ /**
+ * A workspace over the budget seeds NOTHING rather than the prefix that was read: the
+ * Files browser renders this list as the workspace's files, so a truncated seed would
+ * silently hide some. The client fetch reaches the route for the complete list instead.
+ */
+ it('seeds nothing when the workspace exceeds the budget', async () => {
+ mockListWorkspaceFilesWithShares.mockResolvedValue(null)
+ const client = makeClient()
+
+ await seedWorkspaceFiles(client, WORKSPACE_ID)
+
+ expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
+ })
+
+ /** A failed read is an optimization loss, not a render failure. */
+ it('does not throw when the read rejects, and seeds nothing', async () => {
+ mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
+ const client = makeClient()
+
+ await expect(seedWorkspaceFiles(client, WORKSPACE_ID)).resolves.toBeUndefined()
+ expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts
new file mode 100644
index 00000000000..d2a476f3a78
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts
@@ -0,0 +1,48 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { QueryClient } from '@tanstack/react-query'
+import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
+import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
+
+const logger = createLogger('SeedWorkspaceFiles')
+
+/**
+ * How many files a page is willing to inline into its document. At ~500 bytes of JSON per
+ * file this budgets the entry at ~150 KB.
+ *
+ * A workspace above the budget seeds NOTHING rather than a prefix: the Files browser
+ * renders this list as the workspace's files, so a truncated seed would silently hide some.
+ */
+export const WORKSPACE_FILE_SEED_MAX = 300
+
+/**
+ * Seeds the workspace's file list for the pages that render it.
+ *
+ * Seeded rather than prefetched so it can decline to create an entry at all above
+ * {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial
+ * entry would be read as the whole list. Parsed through the route's response contract, so
+ * a seeded entry matches what a client fetch caches.
+ */
+export async function seedWorkspaceFiles(
+ queryClient: QueryClient,
+ workspaceId: string
+): Promise {
+ try {
+ const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
+ maxRows: WORKSPACE_FILE_SEED_MAX,
+ /**
+ * A failed read must reach the catch below, not degrade to an empty list: seeding
+ * `[]` would cache "this workspace has no files" as authoritative for the entry's
+ * lifetime, which is worse than seeding nothing and letting the client fetch.
+ */
+ throwOnError: true,
+ })
+ if (!files) return
+ queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)
+ } catch (error) {
+ /** Optimization only: the client fetch reaches the route instead. */
+ logger.warn('Workspace file list seed failed; client will fetch', {
+ error: getErrorMessage(error),
+ })
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
index e19e970b581..ebabb4df975 100644
--- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
@@ -6,7 +6,6 @@ import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { getUserProfile } from '@/lib/users/queries'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
-import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
@@ -25,7 +24,6 @@ import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query'
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
-import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import {
WORKSPACE_HOST_CONTEXT_STALE_TIME,
workspaceHostKeys,
@@ -92,46 +90,6 @@ async function seedWorkspaceList(
}
}
-/**
- * How many files the layout is willing to inline into the document. Seeded on EVERY
- * workspace route, so at ~500 bytes of JSON per file this budgets the entry at ~150 KB.
- *
- * A workspace above the budget seeds NOTHING rather than a prefix: the sidebar filters
- * this list client-side, so a truncated seed would silently hide files.
- */
-export const WORKSPACE_FILE_SEED_MAX = 300
-
-/**
- * Seeds the workspace's file list, which sidebar chrome registers on EVERY workspace
- * route. It must be seeded HERE, not by the Files pages: `HydrationBoundary` defers a
- * query the cache has already seen to a `useEffect`, which SSR never runs.
- *
- * Seeded rather than prefetched so it can decline to create an entry at all above
- * {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial
- * entry would be read as the whole list. Parsed through the route's response contract.
- */
-async function seedWorkspaceFiles(queryClient: QueryClient, workspaceId: string): Promise {
- try {
- const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
- maxRows: WORKSPACE_FILE_SEED_MAX,
- /**
- * A failed read must reach the catch below, not degrade to an empty list: seeding
- * `[]` would cache "this workspace has no files" as authoritative for the entry's
- * lifetime, which is worse than seeding nothing and letting the client fetch.
- */
- throwOnError: true,
- })
- if (!files) return
- queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)
- } catch (error) {
- /** Optimization only: the client fetch reaches the route instead. Logged so drift between
- * this read and the contract's response schema doesn't degrade silently into a waterfall. */
- logger.warn('Workspace file list seed failed; client will fetch', {
- error: getErrorMessage(error),
- })
- }
-}
-
/**
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions,
* workspace, and viewer-profile reads for a workspace and stores them under the
@@ -190,7 +148,6 @@ export async function prefetchWorkspaceSidebar(
]
: []),
prefetchResourceFolders(queryClient, workspaceId, 'workflow', userId),
- seedWorkspaceFiles(queryClient, workspaceId),
queryClient.prefetchQuery({
queryKey: workspaceKeys.permissions(workspaceId),
queryFn: () =>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
index 625a3de8378..e8a681fc477 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
@@ -72,6 +72,25 @@ vi.mock('@/hooks/use-permission-config', () => ({
}),
}))
+/**
+ * The palette owns these reads now — it mounts only while open, so the queries exist only then.
+ * `mockTables` lets a test drive the Tables section the way the `tables` prop used to.
+ */
+const mockTables = vi.hoisted(() => ({ current: [] as unknown[] }))
+
+vi.mock('@/hooks/queries/tables', () => ({
+ useTablesList: () => ({ data: mockTables.current }),
+}))
+vi.mock('@/hooks/queries/workspace-files', () => ({
+ useWorkspaceFiles: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/kb/knowledge', () => ({
+ useKnowledgeBasesQuery: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/folders', () => ({
+ useFolderMap: () => ({ data: {} }),
+}))
+
vi.mock('@/hooks/use-settings-navigation', () => ({
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
}))
@@ -518,11 +537,9 @@ describe('SearchModal', () => {
})
it('hoists a module page’s actions and its entity section directly under the Sim group', async () => {
- const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }]
+ mockTables.current = [{ id: 'table-1', name: 'Leads', folderId: null }]
await act(async () => {
- root.render(
-
- )
+ root.render()
})
const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map(
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index de345848b10..aeff9869682 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -43,10 +43,12 @@ import { createPortal } from 'react-dom'
import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
+import { getFolderPathNames } from '@/lib/folders/tree'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { captureEvent } from '@/lib/posthog/client'
import { toSearchToken } from '@/lib/search/tokens'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
+import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import {
CommandFadedList,
@@ -86,8 +88,13 @@ import {
CMDK_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
+import { useFolderMap } from '@/hooks/queries/folders'
+import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
+import { useTablesList } from '@/hooks/queries/tables'
+import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import type { WorkflowFolder } from '@/stores/folders/types'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types'
@@ -101,6 +108,9 @@ const logger = createLogger('SearchModal')
export const MAX_BROWSE_RESULTS = Number.POSITIVE_INFINITY
const MAX_SEARCH_RESULTS = 50
+/** Stable empty default so a pending folder map does not remount the memos below. */
+const EMPTY_FOLDER_MAP: Record = {}
+
export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
type SearchModalContentProps = Omit
@@ -121,9 +131,6 @@ function SearchModalContent({
workflows = [],
workspaces = [],
chats = [],
- tables = [],
- files = [],
- knowledgeBases = [],
logs = [],
integrations = [],
connectedAccounts = [],
@@ -157,6 +164,83 @@ function SearchModalContent({
const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data)
+ /**
+ * Read here rather than passed down from the sidebar. This component mounts only while the
+ * palette is open, so these workspace-wide lists are fetched — and registered in the query
+ * cache — only then. The sidebar renders on every workspace route, so holding them there put
+ * three lists and two folder maps in the cache on routes that never display them, and a
+ * registered key also blocks a page from seeding it during the server render.
+ */
+ const { data: fetchedTables = [] } = useTablesList(workspaceId, 'active', {
+ enabled: !permissionConfig.hideTablesTab,
+ })
+ const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId, 'active', {
+ enabled: !permissionConfig.hideFilesTab,
+ })
+ const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
+ enabled: !permissionConfig.hideKnowledgeBaseTab,
+ })
+ const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
+ permissionConfig.hideTablesTab ? undefined : workspaceId,
+ 'table'
+ )
+ const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
+ permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId,
+ 'knowledge_base'
+ )
+
+ /**
+ * The hidden-tab checks are repeated here, not left to `enabled`. A disabled query still
+ * returns whatever is already cached — another surface may have filled it, or the permission
+ * config may have flipped after it did — so gating only the fetch would let the palette list
+ * entities a permission group hides.
+ */
+ const tables = useMemo(
+ () =>
+ permissionConfig.hideTablesTab
+ ? []
+ : fetchedTables.map((t) => ({
+ id: t.id,
+ name: t.name,
+ href: `/workspace/${workspaceId}/tables/${t.id}`,
+ folderPath: getFolderPathNames(tableFolderMap, t.folderId),
+ })),
+ [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab]
+ )
+
+ const files = useMemo(
+ () =>
+ permissionConfig.hideFilesTab
+ ? []
+ : fetchedFiles.map((f) => ({
+ id: f.id,
+ name: f.name,
+ href: `/workspace/${workspaceId}/files/${f.id}`,
+ folderPath: f.folderPath
+ ? parseWorkspaceFileFolderDisplayPath(f.folderPath)
+ : undefined,
+ })),
+ [fetchedFiles, workspaceId, permissionConfig.hideFilesTab]
+ )
+
+ const knowledgeBases = useMemo(
+ () =>
+ permissionConfig.hideKnowledgeBaseTab
+ ? []
+ : fetchedKnowledgeBases.map((kb) => ({
+ id: kb.id,
+ name: kb.name,
+ href: `/workspace/${workspaceId}/knowledge/${kb.id}`,
+ folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId),
+ })),
+ [
+ fetchedKnowledgeBases,
+ knowledgeBaseFolderMap,
+ workspaceId,
+ permissionConfig.hideKnowledgeBaseTab,
+ ]
+ )
+
const openHelpModal = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-help-modal'))
}, [])
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
index 7403ae52c85..35bfd2c12d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
@@ -166,9 +166,12 @@ export interface SearchModalProps {
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
chats?: TaskItem[]
- tables?: FolderedItem[]
- files?: FileItem[]
- knowledgeBases?: FolderedItem[]
+ /**
+ * Tables, files, and knowledge bases are NOT passed in: the content component reads them
+ * itself, so those three workspace-wide lists are queried only while the palette is open.
+ * The sidebar renders on every workspace route, so fetching them there registered the keys
+ * in the cache on routes that never show them.
+ */
logs?: LogItem[]
integrations?: IntegrationSearchItem[]
connectedAccounts?: IntegrationSearchItem[]
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 6a6824eae68..94dafe2a8b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -43,7 +43,6 @@ import { isChatEnabled } from '@/lib/core/config/env-flags'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
-import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -100,7 +99,6 @@ import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
-import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { type LogFilters, useLogsList } from '@/hooks/queries/logs'
import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats'
import {
@@ -112,10 +110,8 @@ import {
useRenameMothershipChat,
useSetMothershipChatPinned,
} from '@/hooks/queries/mothership-chats'
-import { useTablesList } from '@/hooks/queries/tables'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import type { Workspace } from '@/hooks/queries/workspace'
-import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -600,16 +596,6 @@ export const Sidebar = memo(function Sidebar({
useFolders(workspaceId)
const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId)
- // Tables and knowledge bases keep their folders in the generic folder tree,
- // keyed by resource type, so each needs its own map to resolve a path.
- const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
- permissionConfig.hideTablesTab ? undefined : workspaceId,
- 'table'
- )
- const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
- permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId,
- 'knowledge_base'
- )
const updateWorkflowMutation = useUpdateWorkflow()
const folderTree = useMemo(
@@ -903,56 +889,6 @@ export const Sidebar = memo(function Sidebar({
[fetchedChats, workspaceId]
)
- const { data: fetchedTables = [] } = useTablesList(workspaceId)
- const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId)
- const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId)
-
- const searchModalTables = useMemo(
- () =>
- permissionConfig.hideTablesTab
- ? []
- : fetchedTables.map((t) => ({
- id: t.id,
- name: t.name,
- href: `/workspace/${workspaceId}/tables/${t.id}`,
- folderPath: getFolderPathNames(tableFolderMap, t.folderId),
- })),
- [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab]
- )
-
- const searchModalFiles = useMemo(
- () =>
- permissionConfig.hideFilesTab
- ? []
- : fetchedFiles.map((f) => ({
- id: f.id,
- name: f.name,
- href: `/workspace/${workspaceId}/files/${f.id}`,
- folderPath: f.folderPath
- ? parseWorkspaceFileFolderDisplayPath(f.folderPath)
- : undefined,
- })),
- [fetchedFiles, workspaceId, permissionConfig.hideFilesTab]
- )
-
- const searchModalKnowledgeBases = useMemo(
- () =>
- permissionConfig.hideKnowledgeBaseTab
- ? []
- : fetchedKnowledgeBases.map((kb) => ({
- id: kb.id,
- name: kb.name,
- href: `/workspace/${workspaceId}/knowledge/${kb.id}`,
- folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId),
- })),
- [
- fetchedKnowledgeBases,
- knowledgeBaseFolderMap,
- workspaceId,
- permissionConfig.hideKnowledgeBaseTab,
- ]
- )
-
const chatIds = useMemo(() => chats.map((t) => t.id), [chats])
const { selectedChats, handleChatClick } = useChatSelection({ chatIds })
@@ -1928,9 +1864,6 @@ export const Sidebar = memo(function Sidebar({
workflows={searchModalWorkflows}
workspaces={searchModalWorkspaces}
chats={chats}
- tables={searchModalTables}
- files={searchModalFiles}
- knowledgeBases={searchModalKnowledgeBases}
logs={searchModalLogs}
integrations={searchModalIntegrations}
connectedAccounts={searchModalConnectedAccounts}