mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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: () =>
|
||||
|
||||
+21
-4
@@ -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(
|
||||
|
||||
+87
-3
@@ -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'))
|
||||
}, [])
|
||||
|
||||
+6
-3
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user