diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx
index 4d883e9ee3a..71f10cbea0e 100644
--- a/apps/sim/app/account/settings/[section]/page.tsx
+++ b/apps/sim/app/account/settings/[section]/page.tsx
@@ -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'
@@ -50,5 +51,15 @@ export default async function AccountSettingsSectionPage({
if (!isSuperUser) notFound()
}
- return
+ /**
+ * 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 (
+
+
+
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 1d55ca37628..8f47b6a1664 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -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'
@@ -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'
@@ -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,
@@ -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([])
- const [sizeFilter, setSizeFilter] = useState([])
- const [uploadedByFilter, setUploadedByFilter] = useState([])
+ 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)
@@ -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...',
}
@@ -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 =
diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts
index d1c35f80e58..0a093314087 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts
@@ -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
@@ -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
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index be7b5ccd801..c0ab237b3b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -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,
@@ -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'
@@ -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
}
@@ -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([])
- const [contentFilter, setContentFilter] = useState([])
- const [ownerFilter, setOwnerFilter] = useState([])
+ 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)
@@ -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(
@@ -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(
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
index 6243dc42035..402d437b4f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
@@ -1,6 +1,8 @@
+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'
@@ -8,6 +10,12 @@ 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,
}: {
@@ -20,7 +28,9 @@ export default async function KnowledgePage({
return (
-
+ }>
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts
new file mode 100644
index 00000000000..cd35244171d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts
@@ -0,0 +1,53 @@
+import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
+
+/** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */
+export const KNOWLEDGE_SORT_COLUMNS = [
+ 'name',
+ 'documents',
+ 'tokens',
+ 'connectors',
+ 'created',
+ 'owner',
+ 'updated',
+] as const
+
+export type KnowledgeSortColumn = (typeof KNOWLEDGE_SORT_COLUMNS)[number]
+
+const SORT_DIRECTIONS = ['asc', 'desc'] as const
+
+/** Default sort: most-recently-updated first. */
+export const DEFAULT_KNOWLEDGE_SORT_COLUMN: KnowledgeSortColumn = 'updated'
+export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc'
+
+/**
+ * Co-located, typed URL query-param definitions for the Knowledge Base list.
+ *
+ * - `search` is the knowledge base name/description filter. The input is
+ * controlled directly by the instant 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). "No
+ * active sort" is derived in the component as `sort === DEFAULT && dir ===
+ * DEFAULT`.
+ * - `connector` filters by connector presence; `content` filters by document
+ * presence; `owner` filters by creator id. All are multi-select arrays.
+ *
+ * Selecting a knowledge base navigates to the `knowledge/[id]` route (via
+ * `router`), so the active knowledge base is route state, not query state, and
+ * is intentionally not represented here.
+ */
+export const knowledgeParsers = {
+ search: parseAsString.withDefault(''),
+ sort: parseAsStringLiteral(KNOWLEDGE_SORT_COLUMNS).withDefault(DEFAULT_KNOWLEDGE_SORT_COLUMN),
+ dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KNOWLEDGE_SORT_DIRECTION),
+ connector: parseAsArrayOf(parseAsString).withDefault([]),
+ content: parseAsArrayOf(parseAsString).withDefault([]),
+ owner: parseAsArrayOf(parseAsString).withDefault([]),
+} as const
+
+/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
+export const knowledgeUrlKeys = {
+ history: 'replace',
+ shallow: true,
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
index cfdeeaad72d..15028e13f85 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
@@ -26,7 +26,7 @@ import { Download, Workflow } from '@sim/emcn/icons'
import { formatDuration } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
-import { useQueryState } from 'nuqs'
+import { useQueryState, useQueryStates } from 'nuqs'
import type {
WorkflowLogDetail,
WorkflowLogRow,
@@ -58,9 +58,14 @@ import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import {
+ DEFAULT_LOG_SORT_COLUMN,
+ DEFAULT_LOG_SORT_DIRECTION,
executionIdParam,
+ LOG_SORT_COLUMNS,
logDetailsTabParam,
logDetailsTabUrlKeys,
+ logFilterUrlKeys,
+ logSortParsers,
} from '@/app/workspace/[workspaceId]/logs/search-params'
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -97,7 +102,6 @@ import {
} from './utils'
const LOGS_PER_PAGE = 50 as const
-const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
const LIVE_REFRESH_INTERVAL_MS = 10_000 as const
const ACTIVE_RUN_DETAIL_REFRESH_MS = 3_000 as const
@@ -270,10 +274,13 @@ export default function Logs() {
const activeLogRefetchRef = useRef<() => void>(() => {})
const activeLogTabRef = useRef('overview')
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
- const [activeSort, setActiveSort] = useState<{
- column: string
- direction: 'asc' | 'desc'
- } | null>(null)
+
+ /**
+ * URL-backed sort (`sort` + `dir`). The defaults match the server's default
+ * ordering, so a clean URL means "no active sort" and clearing the sort
+ * writes the defaults back (which `clearOnDefault` strips from the URL).
+ */
+ const [sortParams, setSortParams] = useQueryStates(logSortParsers, logFilterUrlKeys)
const userPermissions = useUserPermissionsContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
@@ -301,11 +308,8 @@ export default function Logs() {
refetchInterval,
})
- const sortBy: LogSortBy =
- activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy)
- ? (activeSort.column as LogSortBy)
- : 'date'
- const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc'
+ const sortBy: LogSortBy = sortParams.sort
+ const sortOrder: LogSortOrder = sortParams.dir
const logFilters = useMemo(
() => ({
@@ -1016,11 +1020,18 @@ export default function Logs() {
{ id: 'cost', label: 'Cost' },
{ id: 'status', label: 'Status' },
],
- active: activeSort,
- onSort: (column, direction) => setActiveSort({ column, direction }),
- onClear: () => setActiveSort(null),
+ active:
+ sortParams.sort === DEFAULT_LOG_SORT_COLUMN && sortParams.dir === DEFAULT_LOG_SORT_DIRECTION
+ ? null
+ : { column: sortParams.sort, direction: sortParams.dir },
+ onSort: (column, direction) => {
+ if (!(LOG_SORT_COLUMNS as readonly string[]).includes(column)) return
+ setSortParams({ sort: column as LogSortBy, dir: direction })
+ },
+ onClear: () =>
+ setSortParams({ sort: DEFAULT_LOG_SORT_COLUMN, dir: DEFAULT_LOG_SORT_DIRECTION }),
}),
- [activeSort]
+ [sortParams, setSortParams]
)
const searchConfig = useMemo(
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts
index 643e9f992cc..b68e0d667d3 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts
@@ -1,4 +1,5 @@
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
+import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs'
import {
CORE_TRIGGER_TYPES,
type LogLevel,
@@ -116,6 +117,31 @@ export const logFilterUrlKeys = {
clearOnDefault: true,
} as const
+/** Columns the logs list can sort by; must stay in sync with {@link LogSortBy}. */
+export const LOG_SORT_COLUMNS = [
+ 'date',
+ 'duration',
+ 'cost',
+ 'status',
+] as const satisfies readonly LogSortBy[]
+
+const LOG_SORT_DIRECTIONS = ['asc', 'desc'] as const satisfies readonly LogSortOrder[]
+
+/** Default ordering the server applies when no sort is active (newest first). */
+export const DEFAULT_LOG_SORT_COLUMN: LogSortBy = 'date'
+export const DEFAULT_LOG_SORT_DIRECTION: LogSortOrder = 'desc'
+
+/**
+ * Sort params for the logs resource table (`sort` + `dir`). The defaults match
+ * the server's default ordering exactly, so with `clearOnDefault` a clean URL
+ * means "no active sort" and clearing the sort strips both params. Shares
+ * {@link logFilterUrlKeys} so sort changes replace history like filter changes.
+ */
+export const logSortParsers = {
+ sort: parseAsStringLiteral(LOG_SORT_COLUMNS).withDefault(DEFAULT_LOG_SORT_COLUMN),
+ dir: parseAsStringLiteral(LOG_SORT_DIRECTIONS).withDefault(DEFAULT_LOG_SORT_DIRECTION),
+} as const
+
/**
* Read-only deep link to a specific execution. Resolves to a log row and opens
* the details sidebar on load. Intentionally NOT stripped — the link stays
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index d494850bafb..ed8be3af308 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -6,10 +6,15 @@ import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Info, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { debounce, useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -105,7 +110,11 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [deleteKey, setDeleteKey] = useState(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const defaultKeyType = isPersonalScope
? 'personal'
@@ -184,7 +193,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
void setSearchTerm(value),
placeholder: 'Search API keys...',
}}
actions={actions}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
index 82a6be65d86..40c16c8ff99 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
@@ -15,6 +15,11 @@ import {
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Plus } from 'lucide-react'
+import { debounce, useQueryState } from 'nuqs'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -48,7 +53,11 @@ export function Copilot() {
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [deleteKey, setDeleteKey] = useState(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const [createError, setCreateError] = useState(null)
const filteredKeys = useMemo(() => {
@@ -123,7 +132,7 @@ export function Copilot() {
void setSearchTerm(value),
placeholder: 'Search API keys...',
}}
actions={actions}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
index 7254477ac83..4dd6dc08466 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
@@ -6,9 +6,14 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { debounce, useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -26,7 +31,11 @@ export function CustomTools() {
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
const deleteToolMutation = useDeleteCustomTool()
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const [deletingTools, setDeletingTools] = useState>(() => new Set())
const [editingTool, setEditingTool] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
@@ -108,7 +117,7 @@ export function CustomTools() {
void setSearchTerm(value),
placeholder: 'Search tools...',
disabled: isLoading,
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index 6894bb87684..b6e5d635e16 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { useQueryState } from 'nuqs'
+import { debounce, useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
@@ -24,6 +24,10 @@ import {
mcpServerIdUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -187,7 +191,11 @@ export function MCP() {
const [showAddModal, setShowAddModal] = useState(false)
const [editingServerId, setEditingServerId] = useState(null)
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const [deletingServers, setDeletingServers] = useState>(() => new Set())
const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({
workspaceId,
@@ -261,8 +269,9 @@ export function MCP() {
refetchStoredTools()
}
+ /** Closing replaces the URL — Back should leave the section, not reopen the detail view. */
const handleBackToList = () => {
- setSelectedServerId(null)
+ setSelectedServerId(null, { history: 'replace' })
setExpandedTools(new Set())
}
@@ -617,7 +626,7 @@ export function MCP() {
void setSearchTerm(value),
placeholder: 'Search MCPs...',
}}
actions={
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts
new file mode 100644
index 00000000000..2c9a281770a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts
@@ -0,0 +1,22 @@
+import { parseAsString } from 'nuqs/server'
+
+/**
+ * Shared URL query-param definition for the settings list search boxes
+ * (teammates, api-keys, copilot, custom-tools, mcp, secrets,
+ * workflow-mcp-servers). Settings sections never co-render, so they all share
+ * the `search` key without collisions.
+ *
+ * The input is controlled directly by the nuqs value; only its URL write is
+ * debounced via `limitUrlUpdates` at the hook options.
+ */
+export const settingsSearchParam = {
+ key: 'search',
+ parser: parseAsString.withDefault(''),
+} as const
+
+/** Search view-state: clean URLs, no back-stack churn. */
+export const settingsSearchUrlKeys = {
+ history: 'replace',
+ shallow: true,
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
index 03481c6c7c6..fe5b40c7a0e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
+import { debounce, useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import {
clearPendingCredentialCreateRequest,
@@ -16,6 +17,10 @@ import {
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
@@ -362,7 +367,11 @@ export function SecretsManager() {
const [newWorkspaceRows, setNewWorkspaceRows] = useState([
createEmptyEnvVar(),
])
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const [workspaceVars, setWorkspaceVars] = useState>({})
const [renamingKey, setRenamingKey] = useState(null)
@@ -951,7 +960,7 @@ export function SecretsManager() {
scrollContainerRef={scrollContainerRef}
search={{
value: searchTerm,
- onChange: setSearchTerm,
+ onChange: (value) => void setSearchTerm(value),
placeholder: 'Search secrets...',
}}
actions={[
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts
deleted file mode 100644
index 3366760fbc4..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { parseAsString } from 'nuqs/server'
-
-/**
- * Co-located, typed URL query-param definition for the Teammates view.
- *
- * `search` is the name/email filter. The input is controlled directly by the
- * nuqs value; only its URL write is debounced via `limitUrlUpdates`.
- */
-export const teammatesSearchParam = {
- key: 'search',
- parser: parseAsString.withDefault(''),
-} as const
-
-/** Search view-state: clean URLs, no back-stack churn. */
-export const teammatesUrlKeys = {
- history: 'replace',
- clearOnDefault: true,
-} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
index db0b4b91738..35f48d0ab7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
@@ -21,11 +21,11 @@ import {
MemberSection,
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
-import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
- teammatesSearchParam,
- teammatesUrlKeys,
-} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params'
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
+import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import {
useCancelWorkspaceInvitation,
@@ -74,9 +74,9 @@ export function Teammates() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
- const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, {
- ...teammatesSearchParam.parser,
- ...teammatesUrlKeys,
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
limitUrlUpdates: debounce(300),
})
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
index 984f5cac0bf..6e1aeef0aaf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
@@ -24,12 +24,21 @@ import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Check, Clipboard, Plus, Server } from 'lucide-react'
-import { useParams, useSearchParams } from 'next/navigation'
+import { useParams } from 'next/navigation'
+import { debounce, useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import {
+ mcpServerIdParam,
+ mcpServerIdUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
+import {
+ settingsSearchParam,
+ settingsSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/settings/components/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -871,7 +880,6 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
export function WorkflowMcpServers() {
const params = useParams()
const workspaceId = params.workspaceId as string
- const searchParams = useSearchParams()
const workspacePermissions = useUserPermissionsContext()
const canAdmin = canMutateWorkspaceSettingsSection('workflow-mcp-servers', workspacePermissions)
@@ -879,11 +887,16 @@ export function WorkflowMcpServers() {
const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId)
const deleteServerMutation = useDeleteWorkflowMcpServer()
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
+ ...settingsSearchParam.parser,
+ ...settingsSearchUrlKeys,
+ limitUrlUpdates: debounce(300),
+ })
const [showAddModal, setShowAddModal] = useState(false)
- const [selectedServerId, setSelectedServerId] = useState(() =>
- searchParams.get('mcpServerId')
- )
+ const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, {
+ ...mcpServerIdParam.parser,
+ ...mcpServerIdUrlKeys,
+ })
const [serverToDelete, setServerToDelete] = useState(null)
const [deletingServers, setDeletingServers] = useState>(() => new Set())
@@ -925,13 +938,24 @@ export function WorkflowMcpServers() {
const hasServers = servers.length > 0
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
- if (selectedServerId) {
+ /**
+ * Render the detail view only while the id can still resolve — while the
+ * list loads (a fresh deep link) or when the server exists in it. A stale id
+ * (deleted server restored from an old history entry or a dead link) falls
+ * back to the list instead of a failed detail view; the lingering param is
+ * harmless and gets overwritten by the next selection. Closing replaces the
+ * URL so Back leaves the section rather than reopening the detail view.
+ */
+ const selectedServerResolves =
+ selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId))
+
+ if (selectedServerId && selectedServerResolves) {
return (
setSelectedServerId(null)}
+ onBack={() => setSelectedServerId(null, { history: 'replace' })}
/>
)
}
@@ -953,7 +977,7 @@ export function WorkflowMcpServers() {
void setSearchTerm(value),
placeholder: 'Search servers...',
}}
actions={actions}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
index 4c7cf55fc45..ae63f33ebc4 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
@@ -19,3 +19,21 @@ export const skillIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
+
+/**
+ * `search` filters the skills list by name/description. The input is controlled
+ * directly by the instant nuqs value; only its URL write is debounced via
+ * `limitUrlUpdates` (`debounce`) on the setter — never written on every
+ * keystroke.
+ */
+export const skillSearchParam = {
+ key: 'search',
+ parser: parseAsString.withDefault(''),
+} as const
+
+/** Search is filter view-state: clean URLs, no back-stack churn. */
+export const skillSearchUrlKeys = {
+ history: 'replace',
+ shallow: true,
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
index 182cc48af45..d03949d9756 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
@@ -1,23 +1,30 @@
'use client'
-import { useState } from 'react'
+import { useCallback, useState } from 'react'
import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { useQueryState } from 'nuqs'
+import { debounce, useQueryState } from 'nuqs'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal'
-import { skillIdParam, skillIdUrlKeys } from '@/app/workspace/[workspaceId]/skills/search-params'
+import {
+ skillIdParam,
+ skillIdUrlKeys,
+ skillSearchParam,
+ skillSearchUrlKeys,
+} from '@/app/workspace/[workspaceId]/skills/search-params'
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
const logger = createLogger('SkillsSettings')
const SKILLS_LABEL = 'Skills'
+const SEARCH_DEBOUNCE_MS = 300 as const
+
interface SkillItemProps {
name: string
description: string
@@ -67,7 +74,10 @@ export function Skills() {
const { data: skills = [], isLoading, error } = useSkills(workspaceId)
const deleteSkillMutation = useDeleteSkill()
- const [searchTerm, setSearchTerm] = useState('')
+ const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, {
+ ...skillSearchParam.parser,
+ ...skillSearchUrlKeys,
+ })
const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, {
...skillIdParam.parser,
...skillIdUrlKeys,
@@ -76,6 +86,23 @@ export function Skills() {
const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ /**
+ * The input is controlled directly by the instant nuqs value; only the URL
+ * write is debounced. Filtering below is cheap in-memory over a small list,
+ * so it reads the instant value too. Clearing writes immediately so the
+ * param drops out without lingering.
+ */
+ const setSearchTerm = useCallback(
+ (value: string) => {
+ const next = value.length > 0 ? value : null
+ setSearchTermParam(
+ next,
+ next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
+ )
+ },
+ [setSearchTermParam]
+ )
+
/** Derive the skill being edited from the loaded list — never store the object in the URL. */
const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null