Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b58d9a0
feat(files): search and filter the paged file list by kind, size, and…
waleedlatif1 Oct 29, 2026
1b9376f
perf(files): page the Files browser and resolve files by lookup inste…
waleedlatif1 Oct 29, 2026
da95a83
fix(files): settle deleted files, refused lookups, and stale saved fi…
waleedlatif1 Oct 29, 2026
8ba63c5
feat(files): sort the paged list by type and owner, and rank file sea…
waleedlatif1 Oct 29, 2026
19e36df
feat(files): offer the Type and Owner sorts again on the paged browser
waleedlatif1 Oct 29, 2026
f76c9d3
refactor(files): share the file browsers' type rules and tighten the …
waleedlatif1 Oct 29, 2026
2156cce
perf(files): roll up the child folders of several parents in one fold…
waleedlatif1 Oct 29, 2026
1d14e47
refactor(files): consolidate the paged file reads' client hooks and n…
waleedlatif1 Oct 29, 2026
dde3796
perf(files): resolve a file chip's candidates by reference alone
waleedlatif1 Oct 29, 2026
c94cf78
chore(files): tidy the paged file reads' memos, query options, and co…
waleedlatif1 Oct 29, 2026
adc7f97
test(files): tighten the paged file reads' tests
waleedlatif1 Oct 29, 2026
cd527cd
test(files): drop a redundant global unstub and the deleted queries t…
waleedlatif1 Oct 29, 2026
03d3162
fix(files): address the paged file reads' first review round
waleedlatif1 Oct 29, 2026
6cd7537
fix(files): address the paged file reads' second review round
waleedlatif1 Oct 29, 2026
fcd000a
fix(files): address the paged file reads' third review round
waleedlatif1 Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ describe('GET /api/workspaces/[id]/files/folder-stats', () => {
)

expect(response.status).toBe(200)
expect(stats.mock.calls[0][0].input).toEqual({ workspaceId: WORKSPACE_ID, parentId: null })
expect(stats.mock.calls[0][0].input).toEqual({ workspaceId: WORKSPACE_ID, parentIds: [null] })
})

it('reads several parents at once, the root spelled `root`, in place of parentId', async () => {
const response = await GET(
createMockRequest({
method: 'GET',
url: `/api/workspaces/${WORKSPACE_ID}/files/folder-stats?parentId=folder-9&parentIds=folder-2,root`,
}),
createRouteContext({ id: WORKSPACE_ID })
)

expect(response.status).toBe(200)
expect(stats.mock.calls[0][0].input).toEqual({
workspaceId: WORKSPACE_ID,
parentIds: ['folder-2', null],
})
})
})
7 changes: 6 additions & 1 deletion apps/sim/app/api/workspaces/[id]/files/folder-stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ export const GET = defineInternalJsonRoute({
operation: fileOperations.list,
rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.read' }),
errorPolicy: internalFileErrorPolicies.default,
mapInput: ({ params, query }) => ({ workspaceId: params.id, parentId: query.parentId }),
mapInput: ({ params, query }) => ({
workspaceId: params.id,
parentIds: query.parentIds
? query.parentIds.map((parentId) => (parentId === 'root' ? null : parentId))
: [query.parentId],
}),
useCase: getWorkspaceFileFolderStats,
present: ({ folders }) => ({ success: true as const, folders }),
})
42 changes: 42 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ describe('/api/workspaces/[id]/files', () => {
})
})

it('reads an empty set filter as no filter, keeping the whole-scope listing', async () => {
collection.mockResolvedValueOnce({ files: [FILE], nextKeys: null })
const response = await list('?scope=active&types=&sizes=&uploadedBy=')

expect(response.status).toBe(200)
expect(collection.mock.calls[0][0].input).toEqual({
workspaceId: WORKSPACE_ID,
scope: 'active',
})
})

it('resumes from its own cursor and refuses it under another folder', async () => {
collection.mockResolvedValue({ files: [FILE], nextKeys: ['notes.md', FILE.id] })

Expand All @@ -127,5 +138,36 @@ describe('/api/workspaces/[id]/files', () => {
expect(moved.status).toBe(400)
expect(collection).toHaveBeenCalledTimes(2)
})

it('binds its cursor to the search and set filters, whatever order the sets are spelled in', async () => {
collection.mockResolvedValue({ files: [FILE], nextKeys: ['notes.md', FILE.id] })
const filtered =
'?sortBy=name&limit=1&q=notes&types=image,document&sizes=small&uploadedBy=u1,u2'

const first = await (await list(filtered)).json()
expect(collection.mock.calls[0][0].input.page).toMatchObject({
search: 'notes',
kinds: ['document', 'image'],
sizeBuckets: ['small'],
uploadedBy: ['u1', 'u2'],
})

const respelled = await list(
`?sortBy=name&limit=1&q=notes&types=document,image,image&sizes=small&uploadedBy=u2,u1&cursor=${first.nextCursor}`
)
expect(respelled.status).toBe(200)

for (const changed of [
'q=other&types=image,document&sizes=small&uploadedBy=u1,u2',
'q=notes&types=image&sizes=small&uploadedBy=u1,u2',
'q=notes&types=image,document&sizes=large&uploadedBy=u1,u2',
'q=notes&types=image,document&sizes=small&uploadedBy=u1',
'q=notes&types=image,document&sizes=small&uploadedBy=u1,u2&folderId=root&recursive=true',
]) {
const refiltered = await list(`?sortBy=name&limit=1&${changed}&cursor=${first.nextCursor}`)
expect(refiltered.status).toBe(400)
}
expect(collection).toHaveBeenCalledTimes(2)
})
})
})
57 changes: 6 additions & 51 deletions apps/sim/app/api/workspaces/[id]/files/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import {
createWorkspaceFileContract,
DEFAULT_WORKSPACE_FILE_PAGE_SIZE,
listWorkspaceFilesContract,
WORKSPACE_FILE_PAGE_PARAMS,
} from '@/lib/api/contracts/workspace-files'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import {
defineInternalJsonRoute,
internalRateLimits,
Expand All @@ -16,28 +13,20 @@ import {
internalFileErrorPolicies,
internalFilePresenters,
} from '@/lib/workspace-files/api'
import {
presentWorkspaceFileList,
toWorkspaceFileListInput,
} from '@/lib/workspace-files/api/list-page'
import {
admitCreateWorkspaceFile,
createWorkspaceFile,
} from '@/lib/workspace-files/application/create-workspace-file'
import { listWorkspaceFileCollection } from '@/lib/workspace-files/application/list-workspace-files'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'

export const dynamic = 'force-dynamic'

/** The params besides the sort that change which files a page walk returns; the cursor is bound to them. */
function fileCursorFilters(
workspaceId: string,
query: { scope: 'active' | 'archived'; folderId?: string | null }
) {
return cursorScopeKey(cursorRoute(listWorkspaceFilesContract, { id: workspaceId }), {
scope: query.scope,
folderId: query.folderId,
})
}

/**
* GET /api/workspaces/[id]/files — List workspace files: one bounded page with `nextCursor`
* when any paging param is sent, else the whole scope.
Expand All @@ -50,43 +39,9 @@ export const GET = defineInternalJsonRoute({
// can choose a policy per request.
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }),
errorPolicy: internalFileErrorPolicies.default,
mapInput: ({ params, query }) => {
if (!WORKSPACE_FILE_PAGE_PARAMS.some((param) => query[param] !== undefined)) {
return { workspaceId: params.id, scope: query.scope }
}
const sortBy = query.sortBy ?? 'uploadedAt'
const sortOrder = query.sortOrder ?? 'asc'
return {
workspaceId: params.id,
scope: query.scope,
page: {
folderId: query.folderId,
sortBy,
sortOrder,
limit: query.limit ?? DEFAULT_WORKSPACE_FILE_PAGE_SIZE,
after: readSortedCursor(
query.cursor,
sortBy,
sortOrder,
fileCursorFilters(params.id, query)
),
},
}
},
mapInput: ({ params, query }) => toWorkspaceFileListInput(params.id, query),
useCase: listWorkspaceFileCollection,
present: (result, { input }) => {
const listed = internalFilePresenters.successFiles(result)
if (!input.page) return listed
return {
...listed,
nextCursor: writeSortedCursor(
result.nextKeys,
input.page.sortBy,
input.page.sortOrder,
fileCursorFilters(input.workspaceId, { scope: input.scope, folderId: input.page.folderId })
),
}
},
present: (result, { input }) => presentWorkspaceFileList(result, input),
})

/** POST /api/workspaces/[id]/files — Create an authored workspace file. */
Expand Down
22 changes: 9 additions & 13 deletions apps/sim/app/api/workspaces/[id]/files/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api'
import { listWorkspaceFileCollection } from '@/lib/workspace-files/application/list-workspace-files'
import { searchWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files'
import { fileOperations } from '@/lib/workspace-files/application/operations'

export const dynamic = 'force-dynamic'
Expand All @@ -14,8 +14,9 @@ export const dynamic = 'force-dynamic'
const SEARCH_TYPE_AHEAD_LIMIT = { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 }

/**
* GET /api/workspaces/[id]/files/search — live files whose name contains `q`, by name, each
* carrying its `folderPath`; within `folderId` (and, by default, its subfolders) when given.
* GET /api/workspaces/[id]/files/search — live files `q` finds, best match first: by name
* (exact, prefix, substring, then fuzzy), then by the folders they sit under; each carries its
* `folderPath`. Within `folderId` (and, by default, its subfolders) when given.
*/
export const GET = defineInternalJsonRoute({
contract: searchWorkspaceFilesContract,
Expand All @@ -28,16 +29,11 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalFileErrorPolicies.default,
mapInput: ({ params, query }) => ({
workspaceId: params.id,
scope: 'active' as const,
page: {
search: query.q,
folderId: query.folderId,
recursive: query.recursive,
sortBy: 'name' as const,
sortOrder: 'asc' as const,
limit: query.limit,
},
query: query.q,
folderId: query.folderId,
recursive: query.recursive,
limit: query.limit,
}),
useCase: listWorkspaceFileCollection,
useCase: searchWorkspaceFiles,
present: internalFilePresenters.successFiles,
})
9 changes: 2 additions & 7 deletions apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
useResourcePanelController,
} from '@/app/workspace/[workspaceId]/home/hooks/use-resource-panel'
import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry'
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
import { fetchWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
import { searchFiltersFromParams } from '@/app/workspace/[workspaceId]/home/search-params'
import type {
ChatRequestMode,
Expand All @@ -47,7 +47,6 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files'
import { useHydrated } from '@/hooks/use-hydrated'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
Expand Down Expand Up @@ -180,11 +179,7 @@ function OrganizationHomeContent({
toast.error('This resource is outside this organization.')
return
}
const files =
ref.type === 'file'
? await queryClient.fetchQuery(getWorkspaceFilesQueryOptions(ref.workspaceId))
: []
const resource = resolveWorkspaceResourceRef(ref, files)
const resource = await fetchWorkspaceResourceRef(queryClient, ref.workspaceId, ref)
if (!resource) {
toast.error(`Couldn't find "${ref.title}" in its workspace`)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Link from 'next/link'
import type { ChangelogRelease } from '@/lib/api/contracts/changelog'
import { ReleaseWorkflows } from '@/app/workspace/[workspaceId]/changelog/components/release-workflows/release-workflows'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useAddressedWorkspaceFileRecord } from '@/hooks/queries/workspace-files'
import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files'

interface ReleaseEntryProps {
workspaceId: string
Expand All @@ -34,7 +34,7 @@ export function ReleaseEntry({ workspaceId, release, canEdit, onEditDetails }: R
const saveFailedRef = useRef(false)
const [editingBody, setEditingBody] = useState(false)
const [savingBody, setSavingBody] = useState(false)
const file = useAddressedWorkspaceFileRecord(workspaceId, release.fileId)
const file = useWorkspaceFileRecord(workspaceId, release.fileId)

return (
<article className='grid @min-[760px]/changelog:grid-cols-[132px_minmax(0,1fr)] grid-cols-1 gap-x-10 gap-y-3 border-[var(--border)] border-t pt-12 first:border-t-0 first:pt-0'>
Expand Down Expand Up @@ -95,9 +95,9 @@ export function ReleaseEntry({ workspaceId, release, canEdit, onEditDetails }: R
))}
</div>

{file.error ? (
{file.error || file.data === null ? (
<p role='alert' className='text-[var(--text-error)] text-small'>
{file.error.message}
{file.error?.message ?? 'File not found'}
</p>
) : file.data ? (
<div
Expand Down
23 changes: 10 additions & 13 deletions apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
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'

export const metadata: Metadata = {
title: 'Chat',
Expand All @@ -22,20 +19,20 @@ 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.
// the page still renders on its way out.
if (!isChatEnabled) {
notFound()
}

const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
const userId = session?.user?.id
const queryClient = getQueryClient()
await prefetchHomeSurface(queryClient, workspaceId, userId)
const [{ chatId }, session] = await Promise.all([params, getSession()])
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<HomeFallback />}>
<Home key={chatId} chatId={chatId} userName={session?.user?.name} userId={userId} />
</Suspense>
</HydrationBoundary>
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
/>
</Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'
import {
buildFlyoutEntries,
flyoutLevelFolders,
} from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'

function folder(id: string, name: string, parentId: string | null, updatedAt: string) {
return { id, name, parentId, updatedAt: new Date(updatedAt) }
Expand Down Expand Up @@ -49,3 +52,21 @@ describe('buildFlyoutEntries', () => {
expect(entries.map((entry) => entry.id)).toEqual(['root'])
})
})

describe('flyoutLevelFolders', () => {
const folders = [
folder('docs', 'Docs', null, '2026-01-01'),
folder('sub', 'Sub', 'docs', '2026-01-01'),
folder('orphan', 'Orphan', 'archived-parent', '2026-01-01'),
]

it('lists the root children and the folders whose parent is gone at the root', () => {
expect(flyoutLevelFolders(folders, null).map((entry) => entry.id)).toEqual(['docs', 'orphan'])
})

it("lists only a folder's own children one level down", () => {
expect(flyoutLevelFolders(folders, 'docs').map((entry) => [entry.id, entry.parentId])).toEqual([
['sub', null],
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ export interface BuildFlyoutEntriesParams<Item extends FlyoutItemSource> {
hrefForItem: (item: Item) => string
}

/**
* The folders one lazily built flyout level lists, as root folders of that level: `parentId`'s
* children, and at the root (`null`) also every folder whose parent is not among `folders` — an
* archived or missing parent — which {@link buildFlyoutEntries} surfaces at the root of a
* whole tree, so its contents stay reachable one level at a time too.
*/
export function flyoutLevelFolders<Folder extends FlyoutFolderSource>(
folders: readonly Folder[],
parentId: string | null
): Folder[] {
const folderIds = new Set(folders.map((folder) => folder.id))
return folders
.filter((folder) => {
const parent = folder.parentId ?? null
return parentId === null ? parent === null || !folderIds.has(parent) : parent === parentId
})
.map((folder) => ({ ...folder, parentId: null }))
}

function flyoutSortTime(value: Date | string): number {
const time = value instanceof Date ? value.getTime() : Date.parse(value)
return Number.isNaN(time) ? 0 : time
Expand Down
Loading
Loading