Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
Expand Down Expand Up @@ -50,5 +51,15 @@ export default async function AccountSettingsSectionPage({
if (!isSuperUser) notFound()
}

return <AccountSettingsRenderer section={parsed} />
/**
* Sections read URL query params via nuqs (which uses `useSearchParams`
* internally), so the renderer must sit under a Suspense boundary. The
* `null` fallback matches the existing visual behavior — the sections are
* `next/dynamic` components that render nothing while their chunk loads.
*/
return (
<Suspense fallback={null}>
<AccountSettingsRenderer section={parsed} />
</Suspense>
)
}
93 changes: 76 additions & 17 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Download, Send } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { debounce, useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useLimitUpgradeToast } from '@/lib/billing/client'
Expand Down Expand Up @@ -75,7 +75,14 @@ import {
import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu'
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params'
import {
FILE_SORT_COLUMNS,
type FileSortColumn,
filesFilterParsers,
filesFilterUrlKeys,
filesParsers,
filesUrlKeys,
} from '@/app/workspace/[workspaceId]/files/search-params'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
Expand Down Expand Up @@ -104,6 +111,9 @@ type FileResourceItem =

const logger = createLogger('Files')

/** Debounce window for `search` URL writes and filtering; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 200 as const

const SUPPORTED_EXTENSIONS = [
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
Expand Down Expand Up @@ -244,15 +254,61 @@ export function Files() {
})
const [isDraggingOver, setIsDraggingOver] = useState(false)
const dragCounterRef = useRef(0)
const [inputValue, setInputValue] = useState('')
const debouncedSearchTerm = useDebounce(inputValue, 200)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [sizeFilter, setSizeFilter] = useState<string[]>([])
const [uploadedByFilter, setUploadedByFilter] = useState<string[]>([])
const [
{
search: urlSearchTerm,
sort: sortColumn,
dir: sortDirection,
type: typeFilter,
size: sizeFilter,
uploadedBy: uploadedByFilter,
},
setFileFilters,
] = useQueryStates(filesFilterParsers, filesFilterUrlKeys)

/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. The in-memory filter below still reads a debounced value
* so it doesn't recompute on every keystroke.
*/
const setSearchTerm = useCallback(
(value: string) => {
const next = value.length > 0 ? value : null
setFileFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setFileFilters]
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS)

/**
* `sort`/`dir` are nullable in the URL because "no active sort" is distinct
* from an explicit updated/desc selection: with no sort, files fall back to
* updated/desc but folders to name/asc, while an explicit sort orders both
* sections by the chosen column.
*/
const activeSort = useMemo(
() =>
sortColumn !== null && sortDirection !== null
? { column: sortColumn, direction: sortDirection }
: null,
[sortColumn, sortDirection]
)

const setTypeFilter = useCallback(
(next: string[]) => setFileFilters({ type: next }),
[setFileFilters]
)
const setSizeFilter = useCallback(
(next: string[]) => setFileFilters({ size: next }),
[setFileFilters]
)
const setUploadedByFilter = useCallback(
(next: string[]) => setFileFilters({ uploadedBy: next }),
[setFileFilters]
)

const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
Expand Down Expand Up @@ -1523,9 +1579,9 @@ export function Files() {
}, [canEdit, uploading])

const searchConfig: SearchConfig = {
value: inputValue,
onChange: setInputValue,
onClearAll: () => setInputValue(''),
value: urlSearchTerm,
onChange: setSearchTerm,
onClearAll: () => setSearchTerm(''),
placeholder: 'Search files...',
}

Expand Down Expand Up @@ -1689,10 +1745,13 @@ export function Files() {
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
onSort: (column, direction) => {
if (!(FILE_SORT_COLUMNS as readonly string[]).includes(column)) return
setFileFilters({ sort: column as FileSortColumn, dir: direction })
},
onClear: () => setFileFilters({ sort: null, dir: null }),
}),
[activeSort]
[activeSort, setFileFilters]
)

const hasActiveFilters =
Expand Down
45 changes: 44 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/files/search-params.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { createParser, parseAsString } from 'nuqs/server'
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'

/** Sortable list columns, matching the `Resource.Options` sort menu. */
export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const

export type FileSortColumn = (typeof FILE_SORT_COLUMNS)[number]

const SORT_DIRECTIONS = ['asc', 'desc'] as const

/**
* Parser for the `new` flag. Preserves the prior `?new=1` wire format on
Expand Down Expand Up @@ -47,3 +54,39 @@ export const filesUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const

/**
* Co-located, typed URL query-param definitions for the Files list's
* filter/search/sort view-state, grouped separately from the navigation params
* above because filter writes must never land in the browser history.
*
* - `search` is the file/folder name filter. The input is controlled directly
* by the nuqs value; only its URL write is debounced via `limitUrlUpdates`
* (`debounce`) on the setter — never written on every keystroke.
* - `sort` / `dir` follow the shared sort convention (two scalar params). They
* are intentionally nullable (no `.withDefault`) because "no active sort" is
* behaviorally distinct from explicitly sorting by the fallback column: with
* no sort, files order by updated/desc but folders by name/asc, while an
* explicit updated/desc sorts both sections by updatedAt. Collapsing the
* explicit selection into a clean URL would make that folder ordering
* unreachable. Clearing the sort writes `null`, which strips both params.
* - `type` filters by file kind (document/image/audio/video); `size` filters by
* size bucket (small/medium/large); `uploadedBy` filters by uploader user id
* (URL key `uploaded-by`). All three are multi-select arrays.
*/
export const filesFilterParsers = {
search: parseAsString.withDefault(''),
sort: parseAsStringLiteral(FILE_SORT_COLUMNS),
dir: parseAsStringLiteral(SORT_DIRECTIONS),
type: parseAsArrayOf(parseAsString).withDefault([]),
size: parseAsArrayOf(parseAsString).withDefault([]),
uploadedBy: parseAsArrayOf(parseAsString).withDefault([]),
} as const

/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
export const filesFilterUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
urlKeys: { uploadedBy: 'uploaded-by' },
} as const
97 changes: 81 additions & 16 deletions apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type {
FilterTag,
Expand All @@ -30,6 +31,14 @@ import {
KnowledgeBaseContextMenu,
KnowledgeListContextMenu,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
DEFAULT_KNOWLEDGE_SORT_COLUMN,
DEFAULT_KNOWLEDGE_SORT_DIRECTION,
KNOWLEDGE_SORT_COLUMNS,
type KnowledgeSortColumn,
knowledgeParsers,
knowledgeUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/search-params'
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
Expand All @@ -42,6 +51,9 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'

const logger = createLogger('Knowledge')

/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const

interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number
}
Expand Down Expand Up @@ -143,16 +155,60 @@ export function Knowledge() {
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)

const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
const [contentFilter, setContentFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [
{
search: urlSearchQuery,
sort: sortColumn,
dir: sortDirection,
connector: connectorFilter,
content: contentFilter,
owner: ownerFilter,
},
setKnowledgeFilters,
] = useQueryStates(knowledgeParsers, knowledgeUrlKeys)

/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. The in-memory filter below still reads a debounced
* value so it doesn't recompute on every keystroke.
*/
const setSearchQuery = useCallback(
(value: string) => {
const next = value.length > 0 ? value : null
setKnowledgeFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setKnowledgeFilters]
)
const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS)

/**
* The resolved sort is exposed to the sort menu only when it differs from the
* default, mirroring the prior `null`-means-default semantics.
*/
const activeSort = useMemo(
() =>
sortColumn === DEFAULT_KNOWLEDGE_SORT_COLUMN &&
sortDirection === DEFAULT_KNOWLEDGE_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)

const [searchInputValue, setSearchInputValue] = useState('')
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
const setConnectorFilter = useCallback(
(next: string[]) => setKnowledgeFilters({ connector: next }),
[setKnowledgeFilters]
)
const setContentFilter = useCallback(
(next: string[]) => setKnowledgeFilters({ content: next }),
[setKnowledgeFilters]
)
const setOwnerFilter = useCallback(
(next: string[]) => setKnowledgeFilters({ owner: next }),
[setKnowledgeFilters]
)

const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)

Expand Down Expand Up @@ -402,12 +458,12 @@ export function Knowledge() {

const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: setSearchInputValue,
onClearAll: () => setSearchInputValue(''),
value: urlSearchQuery,
onChange: setSearchQuery,
onClearAll: () => setSearchQuery(''),
placeholder: 'Search knowledge bases...',
}),
[searchInputValue]
[urlSearchQuery, setSearchQuery]
)

const sortConfig: SortConfig = useMemo(
Expand All @@ -422,10 +478,19 @@ export function Knowledge() {
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
onSort: (column, direction) => {
const sort = (KNOWLEDGE_SORT_COLUMNS as readonly string[]).includes(column)
? (column as KnowledgeSortColumn)
: DEFAULT_KNOWLEDGE_SORT_COLUMN
setKnowledgeFilters({ sort, dir: direction })
},
onClear: () =>
setKnowledgeFilters({
sort: DEFAULT_KNOWLEDGE_SORT_COLUMN,
dir: DEFAULT_KNOWLEDGE_SORT_DIRECTION,
}),
}),
[activeSort]
[activeSort, setKnowledgeFilters]
)

const memberOptions: ChipDropdownOption[] = useMemo(
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
import { Knowledge } from './knowledge'

export const metadata: Metadata = {
title: 'Knowledge Base',
}

/**
* Knowledge Base page entry. `Knowledge` reads URL query params via nuqs (which
* uses `useSearchParams` internally), so it must sit under a Suspense boundary.
* The fallback renders the real chrome so a suspend never shows a blank frame;
* the route-level `loading.tsx` covers the navigation/chunk-load transition.
*/
export default async function KnowledgePage({
params,
}: {
Expand All @@ -20,7 +28,9 @@ export default async function KnowledgePage({

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Knowledge />
<Suspense fallback={<KnowledgeLoading />}>
<Knowledge />
</Suspense>
</HydrationBoundary>
)
}
Loading
Loading