perf(workspace): stop the sidebar fetching palette data on every route (#6670)

* refactor(prefetch): give the workspace-file seed its own module and tests

* perf(sidebar): let the palette fetch its own lists, so closed routes never register them

* perf(prefetch): seed the file list on the pages that render it, not every route

* fix(chat): guard the chat page prefetch behind the chat flag
This commit is contained in:
Waleed
2026-08-13 13:39:35 -07:00
committed by GitHub
parent 49ec9a907b
commit 0758df3682
12 changed files with 332 additions and 200 deletions
@@ -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 (
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
</HydrationBoundary>
)
}
@@ -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),
])
}
@@ -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 (
<Suspense fallback={<HomeFallback />}>
<Home userName={session?.user?.name} userId={userId} tableViewsEnabled={tableViewsEnabled} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<HomeFallback />}>
<Home
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
</HydrationBoundary>
)
}
@@ -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<void> {
if (!userId) return
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
if (!hostContext) return
await seedWorkspaceFiles(queryClient, workspaceId)
}
@@ -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.
* 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 the file list, bounded by the document payload budget', async () => {
const files = [{ id: 'file-1', name: 'a.txt' }]
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
it('does not read the workspace file list', async () => {
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.
*/
it('seeds nothing when the workspace exceeds the budget', async () => {
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
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),
@@ -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<string, unknown>()
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()
})
})
@@ -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<void> {
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),
})
}
}
@@ -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<void> {
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: () =>
@@ -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(
<SearchModal open onOpenChange={vi.fn()} pageContext='tables' canEdit tables={tables} />
)
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='tables' canEdit />)
})
const headings = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-group-heading]')).map(
@@ -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<string, WorkflowFolder> = {}
export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
type SearchModalContentProps = Omit<SearchModalProps, 'open'>
@@ -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'))
}, [])
@@ -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[]
@@ -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}