From b53f194398f577ed2ca39e28b33f099b7fa35404 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 16:53:32 -0700 Subject: [PATCH 1/7] improvement(url-state): shared sort/search url-state helpers + task and log selection deep links --- .../commands/you-might-not-need-url-state.md | 9 +- .claude/rules/sim-url-state.md | 62 +++++++----- .../components/integration-grid.tsx | 13 ++- .../models/components/model-directory.tsx | 16 ++-- .../workspace/[workspaceId]/files/files.tsx | 54 ++++------- .../[workspaceId]/files/search-params.ts | 31 +++--- .../integrations/integrations.tsx | 23 ++--- .../knowledge/[id]/[documentId]/document.tsx | 48 +++++++--- .../[id]/[documentId]/search-params.ts | 16 ++++ .../[workspaceId]/knowledge/[id]/base.tsx | 72 ++++++-------- .../knowledge/[id]/search-params.ts | 28 +++--- .../[workspaceId]/knowledge/knowledge.tsx | 58 +++-------- .../[workspaceId]/knowledge/search-params.ts | 24 +++-- .../logs/hooks/use-log-filters.ts | 21 ++-- .../app/workspace/[workspaceId]/logs/logs.tsx | 96 ++++++++++++------- .../[workspaceId]/logs/search-params.ts | 44 +++++---- .../hooks/use-scheduled-tasks.ts | 67 +++++++++++-- .../scheduled-tasks/search-params.ts | 19 +++- .../settings/components/api-keys/api-keys.tsx | 12 +-- .../settings/components/copilot/copilot.tsx | 12 +-- .../components/custom-tools/custom-tools.tsx | 12 +-- .../inbox-task-list/inbox-task-list.tsx | 17 +--- .../settings/components/mcp/mcp.tsx | 13 +-- .../recently-deleted/recently-deleted.tsx | 90 ++++++----------- .../recently-deleted/search-params.ts | 27 +++--- .../settings/components/search-params.ts | 5 +- .../secrets-manager/secrets-manager.tsx | 12 +-- .../components/teammates/teammates.tsx | 12 +-- .../components/use-settings-search.ts | 22 +++++ .../workflow-mcp-servers.tsx | 13 +-- .../workspace/[workspaceId]/skills/skills.tsx | 21 +--- .../tables/[tableId]/search-params.ts | 3 +- .../[workspaceId]/tables/search-params.ts | 25 +++-- .../workspace/[workspaceId]/tables/tables.tsx | 87 +++++------------ apps/sim/hooks/use-debounced-search-setter.ts | 38 ++++++++ apps/sim/hooks/use-url-sort.ts | 80 ++++++++++++++++ apps/sim/lib/url-state/constants.ts | 6 ++ apps/sim/lib/url-state/index.ts | 9 ++ apps/sim/lib/url-state/sort-params.ts | 72 ++++++++++++++ 39 files changed, 720 insertions(+), 569 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts create mode 100644 apps/sim/hooks/use-debounced-search-setter.ts create mode 100644 apps/sim/hooks/use-url-sort.ts create mode 100644 apps/sim/lib/url-state/constants.ts create mode 100644 apps/sim/lib/url-state/index.ts create mode 100644 apps/sim/lib/url-state/sort-params.ts diff --git a/.claude/commands/you-might-not-need-url-state.md b/.claude/commands/you-might-not-need-url-state.md index bcab8ac2db6..309863683ea 100644 --- a/.claude/commands/you-might-not-need-url-state.md +++ b/.claude/commands/you-might-not-need-url-state.md @@ -15,6 +15,10 @@ User arguments: $ARGUMENTS Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -32,14 +36,15 @@ Read these before analyzing: 3. **`window.history.replaceState`/`pushState`** to mutate a param. 4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it. 5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`). -6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## Steps 1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines 2. Analyze the specified scope for the anti-patterns listed above 3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state -4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index b3be536a0a8..a841f40098c 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -127,42 +127,56 @@ If a client param must be re-read server-side after a change, set `shallow: fals ## Debounced text inputs -Use nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options) — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect + a ref-guarded URL→local reconcile effect. The hook's returned value updates instantly (so the input is controlled directly by the nuqs value and stays snappy); only the *URL write* is debounced. Back/forward and deep links flow back natively because the input reads the nuqs value — no reconcile effect needed. +Use `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect, and never hand-roll the debounce wiring inline. The nuqs value updates instantly (the input is controlled directly by it and stays snappy); only the *URL write* is debounced via nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options), which the hook applies for you. Clearing (or a whitespace-only value) writes `null` immediately so the param strips without lingering. -- **Standalone single search param** (`useQueryState`): put `limitUrlUpdates: debounce(300)` in the param's options. -- **Search inside a grouped `useQueryStates`**: keep the group's immediate writes for the discrete filters; pass the option **per call** only on the search setter, never on the whole group: +```typescript +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +// Search inside a grouped useQueryStates — the group's discrete filters keep immediate writes: +const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ search: value }, options)) - ```typescript - import { debounce } from 'nuqs' +// Standalone single param — pass the useQueryState setter directly: +const setSearch = useDebouncedSearchSetter(setSearchParam) - const setSearch = useCallback( - (value: string) => { - const next = value.length > 0 ? value : null - // Immediate update when clearing so the param drops out without lingering. - setFilters({ search: next }, next === null ? undefined : { limitUrlUpdates: debounce(300) }) - }, - [setFilters] - ) - ``` +// Non-default window (e.g. files' 200ms): +const setSearch = useDebouncedSearchSetter(write, { debounceMs: 200 }) +``` -- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, 300)`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly. -- Preserve `.trim()` handling, `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. Import `debounce` from `nuqs` (client) — not `nuqs/server`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations/recently-deleted (cheap in-memory filter, instant value), and tables (filter stays debounced). +- **Never write a trimmed value to a param that controls the input** — trimming on write eats the user's trailing space mid-typing and makes multi-word queries untypable. The hook writes the raw value; trim only for the empty-check (the hook does this) and on *read* where the value feeds a query or filter. +- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, SEARCH_DEBOUNCE_MS)` with `SEARCH_DEBOUNCE_MS` from `@/lib/url-state`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly. +- Settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search` — the shared `?search=` binding for that surface. +- Preserve `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations (cheap in-memory filter, instant value), and tables (filter stays debounced). ## Sort convention (`sort` + `dir`) -Sortable lists use **two scalar params**, never a serialized `{column,direction}` object: +Sortable lists use **two scalar params**, never a serialized `{column,direction}` object. Build them with `createSortParams` from `@/lib/url-state` (in the feature's `search-params.ts`) and consume them with `useUrlSort` from `@/hooks/use-url-sort` — never re-declare `SORT_DIRECTIONS`/default constants or hand-roll the `activeSort`/`onSort`/`onClear` wiring: ```typescript -const SORT_COLUMNS = ['name', 'created', 'updated'] as const -const SORT_DIRECTIONS = ['asc', 'desc'] as const +// search-params.ts (server-safe) +import { createSortParams } from '@/lib/url-state' -export const thingsParsers = { - sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'), -} as const +const THING_SORT_COLUMNS = ['name', 'created', 'updated'] as const + +export const thingsSortParams = createSortParams(THING_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) ``` -Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns. +```typescript +// component (client) +import { useUrlSort } from '@/hooks/use-url-sort' + +const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams, thingsUrlKeys) +// activeSort/onSort/onClear plug straight into SortConfig; sort/dir feed query keys and comparators. +``` + +Two modes, chosen by whether you pass a default: + +- **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state. +- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). + +Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there. ## Dates in the URL (date-only params) diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx index 5519b97313b..c9aaa0093cd 100644 --- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx +++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { ChipInput, Search } from '@sim/emcn' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { blockTypeToIconMap, formatIntegrationType, @@ -13,9 +13,7 @@ import { integrationsParsers, integrationsUrlKeys, } from '@/app/(landing)/integrations/search-params' - -/** Debounce window for writing the search term to the URL (filtering is instant). */ -const SEARCH_DEBOUNCE_MS = 300 +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const PILL_BASE = 'rounded-[5px] border border-[var(--border-1)] px-[9px] py-0.5 text-small text-[var(--text-primary)] transition-colors' as const @@ -33,6 +31,9 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) { ) const activeCategory = category || null + /** Debounced `q` URL write; the input stays instant and clearing strips the param immediately. */ + const setQuery = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options)) + /** Category facets, derived once from the (stable) integration list. */ const availableCategories = useMemo(() => { const counts = new Map() @@ -66,9 +67,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) { type='search' placeholder='Search integrations, tools, or triggers…' value={query} - onChange={(e) => - setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }) - } + onChange={(e) => setQuery(e.target.value)} aria-label='Search integrations' /> diff --git a/apps/sim/app/(landing)/models/components/model-directory.tsx b/apps/sim/app/(landing)/models/components/model-directory.tsx index 2bbdbb14311..60ce1eedfde 100644 --- a/apps/sim/app/(landing)/models/components/model-directory.tsx +++ b/apps/sim/app/(landing)/models/components/model-directory.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { ChipInput, Search } from '@sim/emcn' import Link from 'next/link' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow' import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives' import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params' @@ -15,9 +15,7 @@ import { MODEL_PROVIDERS_WITH_CATALOGS, MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS, } from '@/app/(landing)/models/utils' - -/** Debounce window for writing the search term to the URL (filtering is instant). */ -const SEARCH_DEBOUNCE_MS = 300 +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({ id: provider.id, @@ -29,6 +27,9 @@ export function ModelDirectory() { const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys) const activeProviderId = provider || null + /** Debounced `q` URL write; the input stays instant and clearing strips the param immediately. */ + const setQuery = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options)) + const normalizedQuery = query.trim().toLowerCase() const { filteredProviders, filteredDynamicProviders } = useMemo(() => { @@ -90,12 +91,7 @@ export function ModelDirectory() { type='search' placeholder='Search models, providers, or capabilities…' value={query} - onChange={(event) => - setParams( - { q: event.target.value }, - { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - } + onChange={(event) => setQuery(event.target.value)} aria-label='Search AI models' /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 8f47b6a1664..e24996ae1e2 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 { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' @@ -76,11 +76,10 @@ import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/compon import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { - FILE_SORT_COLUMNS, - type FileSortColumn, filesFilterParsers, filesFilterUrlKeys, filesParsers, + filesSortParams, filesUrlKeys, } from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -101,8 +100,10 @@ import { useWorkspaceFiles, } from '@/hooks/queries/workspace-files' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' type FileResourceItem = @@ -111,8 +112,11 @@ 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 +/** + * Debounce window for `search` URL writes and filtering; the input itself stays + * instant. Intentionally shorter than the shared `SEARCH_DEBOUNCE_MS` (300). + */ +const FILES_SEARCH_DEBOUNCE_MS = 200 as const const SUPPORTED_EXTENSIONS = [ ...SUPPORTED_DOCUMENT_EXTENSIONS, @@ -255,14 +259,7 @@ export function Files() { const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) const [ - { - search: urlSearchTerm, - sort: sortColumn, - dir: sortDirection, - type: typeFilter, - size: sizeFilter, - uploadedBy: uploadedByFilter, - }, + { search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter }, setFileFilters, ] = useQueryStates(filesFilterParsers, filesFilterUrlKeys) @@ -271,17 +268,11 @@ export function Files() { * 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 setSearchTerm = useDebouncedSearchSetter( + (value, options) => setFileFilters({ search: value }, options), + { debounceMs: FILES_SEARCH_DEBOUNCE_MS } ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) + const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) /** * `sort`/`dir` are nullable in the URL because "no active sort" is distinct @@ -289,13 +280,7 @@ export function Files() { * 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 { activeSort, onSort, onClear } = useUrlSort(filesSortParams, filesFilterUrlKeys) const setTypeFilter = useCallback( (next: string[]) => setFileFilters({ type: next }), @@ -1745,13 +1730,10 @@ export function Files() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - 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 }), + onSort, + onClear, }), - [activeSort, setFileFilters] + [activeSort, onSort, onClear] ) 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 0a093314087..8ef1d7c3803 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -1,12 +1,9 @@ -import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { createParser, parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** 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 * serialize while tolerantly accepting the legacy `1`/`true` tokens on parse, so @@ -61,28 +58,30 @@ export const filesUrlKeys = { * 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. + * by the nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. * - `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 +/** + * `sort` / `dir` follow the shared sort convention (two scalar params) in + * nullable mode (no `defaultSort`) 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. + */ +export const filesSortParams = createSortParams(FILE_SORT_COLUMNS) + /** Filter/search/sort view-state: clean URLs, no back-stack churn. */ export const filesFilterUrlKeys = { history: 'replace', diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 8da0b7358e8..26c2aa0b3a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -14,7 +14,7 @@ import { } from '@sim/emcn' import Link from 'next/link' import { useParams } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { blockTypeToIconMap, formatIntegrationType, @@ -35,9 +35,7 @@ import { integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' - -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const @@ -145,19 +143,12 @@ export function Integrations() { /** * The input is controlled directly by the instant nuqs value; only the URL - * write is debounced. Filtering below is cheap in-memory over a static list, - * so it reads the instant value too. + * write is debounced. The raw value is written (trimming happens on read in + * the filters below) so trailing spaces stay typable mid-word. Filtering is + * cheap in-memory over a static list, so it reads the instant value too. */ - const setSearchTerm = useCallback( - (value: string) => { - const trimmed = value.trim() - const next = trimmed.length > 0 ? trimmed : null - setIntegrationFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setIntegrationFilters] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setIntegrationFilters({ search: value }, options) ) const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 33d2e17d9b2..e9d41560683 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -34,6 +34,7 @@ import { DocumentTagsModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { + documentChunkSortParams, documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' @@ -51,10 +52,19 @@ import { useUpdateDocument, } from '@/hooks/queries/kb/knowledge' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('Document') +/** + * Debounce window for chunk-search URL writes and the query feed; the input + * itself stays instant. Intentionally shorter than the shared + * `SEARCH_DEBOUNCE_MS` (300) to match the chunk search's snappier feel. + */ +const CHUNK_SEARCH_DEBOUNCE_MS = 200 as const + type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' interface UnsavedChangesModalProps { @@ -127,10 +137,10 @@ export function Document({ }: DocumentProps) { const { workspaceId } = useParams() const router = useRouter() - const [{ page: currentPageFromURL, chunk: chunkFromURL }, setDocumentParams] = useQueryStates( - documentParsers, - documentUrlKeys - ) + const [ + { page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery }, + setDocumentParams, + ] = useQueryStates(documentParsers, documentUrlKeys) const userPermissions = useUserPermissionsContext() const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) @@ -138,13 +148,22 @@ export function Document({ const [showTagsModal, setShowTagsModal] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const debouncedSearchQuery = useDebounce(searchQuery, 200) + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The chunk search query below reads a debounced value so + * it doesn't refetch on every keystroke. + */ + const handleSearchChange = useDebouncedSearchSetter( + (value, options) => void setDocumentParams({ search: value }, options), + { debounceMs: CHUNK_SEARCH_DEBOUNCE_MS } + ) + const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS) const [enabledFilter, setEnabledFilter] = useState([]) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) + const { + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(documentChunkSortParams, documentUrlKeys) const enabledFilterParam = useMemo( () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), @@ -591,7 +610,7 @@ export function Document({ const searchConfig: SearchConfig | undefined = isCompleted ? { value: searchQuery, - onChange: (value: string) => setSearchQuery(value), + onChange: handleSearchChange, placeholder: 'Search chunks...', } : undefined @@ -861,16 +880,17 @@ export function Document({ { id: 'status', label: 'Status' }, ], active: activeSort, + /** Sorting (or clearing the sort) resets pagination to the first page. */ onSort: (column, direction) => { - setActiveSort({ column, direction }) + onSortColumn(column, direction) void goToPage(1) }, onClear: () => { - setActiveSort(null) + onClearSort() void goToPage(1) }, }), - [activeSort, goToPage] + [activeSort, onSortColumn, onClearSort, goToPage] ) const chunkRows: ResourceRow[] = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts index a2a7fb89346..072e08d66ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -1,4 +1,16 @@ import { parseAsInteger, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' + +/** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */ +export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const + +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`), in + * nullable mode: with no active sort the chunk query omits `sortBy` entirely + * (server default order), which is distinct from any explicit column, so an + * explicit selection always persists in the URL and clearing strips both. + */ +export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS) /** * Co-located, typed URL query-param definitions for the knowledge document @@ -9,10 +21,14 @@ import { parseAsInteger, parseAsString } from 'nuqs/server' * to 1 and clears from the URL at the default to keep links clean. * - `chunk` deep-links a specific chunk so it can be focused/opened in the inline * editor from a shared link. + * - `search` is the chunk content search. The input is controlled directly by + * the instant nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const documentParsers = { page: parseAsInteger.withDefault(1), chunk: parseAsString, + search: parseAsString.withDefault(''), } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 2d6863b3250..205de1bd764 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -29,7 +29,7 @@ import { generateId } from '@sim/utils/id' import { format } from 'date-fns' import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryState, useQueryStates } from 'nuqs' +import { useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { SearchHighlight } from '@/components/ui/search-highlight' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' @@ -38,6 +38,7 @@ import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/ import type { DocumentData } from '@/lib/knowledge/types' import { captureEvent } from '@/lib/posthog/client' import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { BreadcrumbItem, FilterTag, @@ -61,11 +62,9 @@ import { } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { addConnectorParam, - DEFAULT_KB_SORT_COLUMN, - DEFAULT_KB_SORT_DIRECTION, documentFiltersParsers, documentFiltersUrlKeys, - type KbSortColumn, + kbDocumentSortParams, pageParam, pageUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' @@ -92,8 +91,10 @@ import { useUpdateKnowledgeBase, } from '@/hooks/queries/kb/knowledge' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('KnowledgeBase') @@ -296,41 +297,31 @@ export function KnowledgeBase({ ...pageUrlKeys, }) - const [ - { q: searchQuery, enabled: enabledFilter, sort: sortColumn, dir: sortDirection }, - setDocumentFilters, - ] = useQueryStates(documentFiltersParsers, documentFiltersUrlKeys) + const [{ q: searchQuery, enabled: enabledFilter }, setDocumentFilters] = useQueryStates( + documentFiltersParsers, + documentFiltersUrlKeys + ) /** * The input is controlled directly by the instant nuqs value; only the URL * write is debounced. The document query below reads a debounced value so it * doesn't refetch on every keystroke. Changing the search resets pagination. */ - const handleSearchChange = useCallback( - (newQuery: string) => { - const trimmed = newQuery.trim() - const next = trimmed.length > 0 ? trimmed : null - setDocumentFilters( - { q: next }, - next === null ? undefined : { limitUrlUpdates: debounce(300) } - ) - setCurrentPage(1) - }, - [setDocumentFilters, setCurrentPage] - ) - const debouncedSearchQuery = useDebounce(searchQuery, 300) + const handleSearchChange = useDebouncedSearchSetter((value, options) => { + setDocumentFilters({ q: value }, options) + setCurrentPage(1) + }) + const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) + /** Raw URL value drives the input; matching/highlighting always sees it trimmed. */ + const highlightQuery = searchQuery.trim() - /** - * 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_KB_SORT_COLUMN && sortDirection === DEFAULT_KB_SORT_DIRECTION - ? null - : { column: sortColumn, direction: sortDirection }, - [sortColumn, sortDirection] - ) + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(kbDocumentSortParams, documentFiltersUrlKeys) const setEnabledFilter = useCallback( (value: 'all' | 'enabled' | 'disabled') => { @@ -385,7 +376,7 @@ export function KnowledgeBase({ updateDocument, refreshDocuments, } = useKnowledgeBaseDocuments(id, { - search: debouncedSearchQuery || undefined, + search: debouncedSearchQuery.trim() || undefined, limit: DOCUMENTS_PER_PAGE, offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, sortBy: sortColumn as DocumentSortField, @@ -914,20 +905,17 @@ export function KnowledgeBase({ { id: 'enabled', label: 'Status' }, ], active: activeSort, + /** Sorting (or clearing the sort) resets pagination to the first page. */ onSort: (column, direction) => { - setDocumentFilters({ sort: column as KbSortColumn, dir: direction }) + onSortColumn(column, direction) setCurrentPage(1) }, - /** - * Clearing writes the defaults back (stripped by clearOnDefault), so the - * sort menu reads "no active sort" again and the URL stays clean. - */ onClear: () => { - setDocumentFilters({ sort: DEFAULT_KB_SORT_COLUMN, dir: DEFAULT_KB_SORT_DIRECTION }) + onClearSort() setCurrentPage(1) }, }), - [activeSort, setDocumentFilters, setCurrentPage] + [activeSort, onSortColumn, onClearSort, setCurrentPage] ) const filterContent = useMemo( @@ -1130,7 +1118,7 @@ export function KnowledgeBase({ label={doc.filename} className={cn('block', chipContentLabelClass)} > - + ), @@ -1166,7 +1154,7 @@ export function KnowledgeBase({ }, } }), - [documents, tagDefinitions, searchQuery] + [documents, tagDefinitions, highlightQuery] ) if (error && !knowledgeBase) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts index 39a57eddc86..c7f1ae8f27e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -1,5 +1,6 @@ import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' +import { createSortParams } from '@/lib/url-state' /** * Co-located, typed URL query-param definitions for the knowledge base detail @@ -44,24 +45,23 @@ export const KB_SORT_COLUMNS = [ 'enabled', ] as const -export type KbSortColumn = (typeof KB_SORT_COLUMNS)[number] - -const SORT_DIRECTIONS = ['asc', 'desc'] as const - -/** Default sort: most-recently-uploaded first (matches the document query default). */ -export const DEFAULT_KB_SORT_COLUMN = 'uploadedAt' -export const DEFAULT_KB_SORT_DIRECTION = 'desc' +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The + * default (most-recently-uploaded first) matches the document query's default + * order, so a clean URL means the default sort. + */ +export const kbDocumentSortParams = createSortParams(KB_SORT_COLUMNS, { + column: 'uploadedAt', + direction: 'desc', +}) /** - * Grouped filter/search/sort URL state for the document list. + * Grouped filter/search URL state for the document list. * * - `q` is the document name search. The input is controlled directly by the - * instant nuqs value; only its URL write is debounced via `limitUrlUpdates` - * on the setter — never written on every keystroke. + * instant nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. * - `enabled` filters by processing/enabled status (`all` clears from the URL). - * - `sort` / `dir` follow the shared `sort`+`dir` convention. The defaults match - * the document query's default order; "no active sort" is derived in the - * component as `sort === DEFAULT && dir === DEFAULT`. * * `tagFilterEntries` is intentionally NOT represented here: it is an array of * rich filter-rule objects (slot, field type, operator, value, value-to per @@ -71,8 +71,6 @@ export const DEFAULT_KB_SORT_DIRECTION = 'desc' export const documentFiltersParsers = { q: parseAsString.withDefault(''), enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'), - sort: parseAsStringLiteral(KB_SORT_COLUMNS).withDefault(DEFAULT_KB_SORT_COLUMN), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KB_SORT_DIRECTION), } as const /** Filter/search/sort view-state: clean URLs, no back-stack churn. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index c0ab237b3b6..d89e8683fc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -6,8 +6,9 @@ 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 { useQueryStates } from 'nuqs' import type { KnowledgeBaseData } from '@/lib/knowledge/types' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -32,11 +33,8 @@ import { KnowledgeListContextMenu, } from '@/app/workspace/[workspaceId]/knowledge/components' import { - DEFAULT_KNOWLEDGE_SORT_COLUMN, - DEFAULT_KNOWLEDGE_SORT_DIRECTION, - KNOWLEDGE_SORT_COLUMNS, - type KnowledgeSortColumn, knowledgeParsers, + knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' @@ -47,13 +45,12 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' 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 } @@ -158,8 +155,6 @@ export function Knowledge() { const [ { search: urlSearchQuery, - sort: sortColumn, - dir: sortDirection, connector: connectorFilter, content: contentFilter, owner: ownerFilter, @@ -172,30 +167,16 @@ export function Knowledge() { * 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 setSearchQuery = useDebouncedSearchSetter((value, options) => + setKnowledgeFilters({ search: value }, options) ) 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 { + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(knowledgeSortParams, knowledgeUrlKeys) const setConnectorFilter = useCallback( (next: string[]) => setKnowledgeFilters({ connector: next }), @@ -478,19 +459,10 @@ export function Knowledge() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - 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, - }), + onSort: onSortColumn, + onClear: onClearSort, }), - [activeSort, setKnowledgeFilters] + [activeSort, onSortColumn, onClearSort] ) const memberOptions: ChipDropdownOption[] = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts index cd35244171d..8a10c0796d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -1,4 +1,5 @@ -import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */ export const KNOWLEDGE_SORT_COLUMNS = [ @@ -11,13 +12,15 @@ export const KNOWLEDGE_SORT_COLUMNS = [ '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' +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The + * default (most-recently-updated first) matches the list's default ordering, + * so a clean URL means the default sort. + */ +export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) /** * Co-located, typed URL query-param definitions for the Knowledge Base list. @@ -26,9 +29,6 @@ export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc' * 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. * @@ -38,8 +38,6 @@ export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc' */ 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([]), diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts index f8cf3ad8fa6..b3a228ac373 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts @@ -1,18 +1,16 @@ 'use client' import { useCallback, useMemo } from 'react' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { logFilterParsers, logFilterUrlKeys, } from '@/app/workspace/[workspaceId]/logs/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types' const DEFAULT_TIME_RANGE: TimeRange = 'All time' -/** Debounce window for `search` URL writes; keystrokes stay instant in the input. */ -const SEARCH_DEBOUNCE_MS = 300 as const - /** * The logs filter state, sourced entirely from typed URL query params via nuqs. * @@ -118,18 +116,11 @@ export function useLogFilters(): UseLogFilters { /** * Debounces only the search param's URL write; the returned `filters.search` * value still updates instantly so the controlled input stays responsive. - * Clearing flushes immediately so the param drops out without lingering. + * Writes the raw value (query consumers trim on read); clearing flushes + * immediately so the param drops out without lingering. */ - const setSearchQuery = useCallback( - (query: string) => { - const trimmed = query.trim() - const next = trimmed.length > 0 ? trimmed : null - setFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setFilters] + const setSearchQuery = useDebouncedSearchSetter((value, options) => + setFilters({ search: value }, options) ) const setTriggers = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 15028e13f85..9d0c5b743e2 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, useQueryStates } from 'nuqs' +import { useQueryState } from 'nuqs' import type { WorkflowLogDetail, WorkflowLogRow, @@ -46,6 +46,7 @@ import { type TriggerData, type WorkflowData, } from '@/lib/logs/search-suggestions' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -58,20 +59,17 @@ 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, + executionIdWriteOptions, logDetailsTabParam, logDetailsTabUrlKeys, logFilterUrlKeys, - logSortParsers, + logSortParams, } 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' import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' -import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' import { fetchLogDetail, logKeys, @@ -85,6 +83,7 @@ import { } from '@/hooks/queries/logs' import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' +import { useUrlSort } from '@/hooks/use-url-sort' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' @@ -241,7 +240,7 @@ export default function Logs() { isSidebarOpen: false, }) - const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser) + const [executionId, setExecutionId] = useQueryState(executionIdParam.key, executionIdParam.parser) const [pendingExecutionId, setPendingExecutionId] = useState(() => executionId) /** @@ -257,9 +256,10 @@ export default function Logs() { /** * `urlSearchQuery` is the instant nuqs value (its URL write is debounced inside * `useLogFilters`); the query/filtering still debounce off it to avoid - * per-keystroke fetches. + * per-keystroke fetches. The raw value is written to the URL, so trim here on + * read — the server keeps receiving a trimmed query. */ - const debouncedSearchQuery = useDebounce(urlSearchQuery, 300) + const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS).trim() const isLive = true const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) @@ -268,6 +268,7 @@ export default function Logs() { const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) + const isSidebarOpenRef = useRef(false) const shouldScrollIntoViewRef = useRef(false) const resourceTableRef = useRef(null) const logsRefetchRef = useRef<() => void>(() => {}) @@ -280,7 +281,13 @@ export default function Logs() { * 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 { + sort: sortBy, + dir: sortOrder, + activeSort, + onSort, + onClear: onClearSort, + } = useUrlSort(logSortParams, logFilterUrlKeys) const userPermissions = useUserPermissionsContext() const [contextMenuOpen, setContextMenuOpen] = useState(false) @@ -308,9 +315,6 @@ export default function Logs() { refetchInterval, }) - const sortBy: LogSortBy = sortParams.sort - const sortOrder: LogSortOrder = sortParams.dir - const logFilters = useMemo( () => ({ timeRange, @@ -381,6 +385,7 @@ export default function Logs() { logsRef.current = logs selectedLogIndexRef.current = selectedLogIndex selectedLogIdRef.current = selectedLogId + isSidebarOpenRef.current = isSidebarOpen logsRefetchRef.current = logsQuery.refetch activeLogRefetchRef.current = selectedDetailQuery.refetch logsQueryRef.current = { @@ -410,31 +415,55 @@ export default function Logs() { } }, []) - const handleLogClick = useCallback((rowId: string) => { - dispatch({ type: 'TOGGLE_LOG', logId: rowId }) - }, []) + /** + * Mirrors the reducer's TOGGLE_LOG branch: clicking the already-open row + * closes the sidebar (strip `executionId`); any other click opens the row + * (sync `executionId` to it so the URL always deep-links the open run). + */ + const handleLogClick = useCallback( + (rowId: string) => { + const opens = !(selectedLogIdRef.current === rowId && isSidebarOpenRef.current) + dispatch({ type: 'TOGGLE_LOG', logId: rowId }) + if (opens) { + const log = logsRef.current.find((l) => l.id === rowId) + setExecutionId(log?.executionId ?? null, executionIdWriteOptions) + } else { + setExecutionId(null, executionIdWriteOptions) + } + }, + [setExecutionId] + ) const handleNavigateNext = useCallback(() => { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current if (idx >= 0 && idx < currentLogs.length - 1) { + const nextLog = currentLogs[idx + 1] shouldScrollIntoViewRef.current = true - dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) + dispatch({ type: 'SELECT_LOG', logId: nextLog.id }) + if (isSidebarOpenRef.current) { + setExecutionId(nextLog.executionId ?? null, executionIdWriteOptions) + } } - }, []) + }, [setExecutionId]) const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current if (idx > 0) { + const prevLog = logsRef.current[idx - 1] shouldScrollIntoViewRef.current = true - dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) + dispatch({ type: 'SELECT_LOG', logId: prevLog.id }) + if (isSidebarOpenRef.current) { + setExecutionId(prevLog.executionId ?? null, executionIdWriteOptions) + } } - }, []) + }, [setExecutionId]) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) + setExecutionId(null, executionIdWriteOptions) activeLogTabRef.current = 'overview' - }, []) + }, [setExecutionId]) /** * Strip the `tab` param whenever the detail panel transitions from open to @@ -663,6 +692,9 @@ export default function Logs() { const handleNavigateNextEvent = useEffectEvent(handleNavigateNext) const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev) + const writeExecutionIdEvent = useEffectEvent((value: string | null) => { + setExecutionId(value, executionIdWriteOptions) + }) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -697,7 +729,14 @@ export default function Logs() { if (e.key === 'Enter' && selectedLogIdRef.current) { e.preventDefault() + const willOpen = !isSidebarOpenRef.current dispatch({ type: 'TOGGLE_SIDEBAR' }) + if (willOpen) { + const log = currentLogs.find((l) => l.id === selectedLogIdRef.current) + writeExecutionIdEvent(log?.executionId ?? null) + } else { + writeExecutionIdEvent(null) + } } } @@ -1020,18 +1059,11 @@ export default function Logs() { { id: 'cost', label: 'Cost' }, { id: 'status', label: 'Status' }, ], - 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 }), + active: activeSort, + onSort, + onClear: onClearSort, }), - [sortParams, setSortParams] + [activeSort, onSort, onClearSort] ) 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 b68e0d667d3..d2171915c21 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -1,5 +1,6 @@ import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' -import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' +import { createSortParams } from '@/lib/url-state' +import type { LogSortBy } from '@/hooks/queries/logs' import { CORE_TRIGGER_TYPES, type LogLevel, @@ -13,8 +14,9 @@ import { * single source of truth. * * The encoding here intentionally preserves the exact wire format the logs page - * shipped before nuqs: `timeRange` uses kebab tokens, `level` / `workflowIds` / - * `folderIds` / `triggers` are comma-joined, and `search` is trimmed. + * shipped before nuqs: `timeRange` uses kebab tokens and `level` / + * `workflowIds` / `folderIds` / `triggers` are comma-joined. `search` carries + * the raw input value (consumers trim on read). */ const DEFAULT_TIME_RANGE: TimeRange = 'All time' @@ -125,33 +127,37 @@ export const LOG_SORT_COLUMNS = [ '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. + * the server's default ordering exactly (newest first), 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 +export const logSortParams = createSortParams(LOG_SORT_COLUMNS, { + column: 'date', + direction: 'desc', +}) /** - * 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 - * shareable — so it carries no `clearOnDefault`/`history` options here. + * Deep link to a specific execution. On load it resolves to a log row and + * opens the details sidebar; from then on it is kept in sync with the open + * run — row click, Enter, the sidebar's next/prev navigation, and closing the + * panel all write it — so the address bar always matches the open log and the + * link stays shareable. */ export const executionIdParam = { key: 'executionId', parser: parseAsString, } as const +/** + * Options for every `executionId` write. Browsing runs is view-state, not a + * destination — `replace` keeps rapid row-to-row navigation from flooding the + * browser back stack. + */ +export const executionIdWriteOptions = { history: 'replace' } as const + const LOG_DETAILS_TABS = ['overview', 'trace'] as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts index 88f03fcdc9e..4b00d1247fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts @@ -1,13 +1,18 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { truncate } from '@sim/utils/string' +import { useQueryState } from 'nuqs' import type { CreateScheduleBody, UpdateScheduleBody } from '@/lib/api/contracts/schedules' import { zonedWallClock } from '@/lib/core/utils/timezone' import type { TaskDraft, TaskEditSeed, } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal' +import { + taskIdParam, + taskIdUrlKeys, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params' import { cronToRecurrence, recurrenceToScheduleFields, @@ -110,8 +115,10 @@ export interface UseScheduledTasksReturn { /** * Bridges the calendar to the persisted job-schedule backend: reads the * workspace's scheduled tasks, expands them into the occurrences visible in the - * current range, and exposes create/edit/delete mutations. UI-only selection - * state lives here; all task data flows through React Query. + * current range, and exposes create/edit/delete mutations. The open task lives + * in the URL (`?taskId=`, deep-linkable — see {@link taskIdParam}) and the task + * object is derived from the loaded occurrences; all task data flows through + * React Query. */ export function useScheduledTasks({ workspaceId, @@ -126,7 +133,10 @@ export function useScheduledTasks({ const disableSchedule = useDisableSchedule() const resumeSchedule = useResumeSchedule() - const [selectedTask, setSelectedTask] = useState(null) + const [taskId, setTaskId] = useQueryState(taskIdParam.key, { + ...taskIdParam.parser, + ...taskIdUrlKeys, + }) const events = useMemo(() => { const now = new Date() @@ -139,8 +149,45 @@ export function useScheduledTasks({ const eventsByDay = useMemo(() => bucketEventsByDay(events), [events]) - const openTask = useCallback((task: ScheduledTask) => setSelectedTask(task), []) - const closeTask = useCallback(() => setSelectedTask(null), []) + /** + * Occurrence lookup for the `?taskId=` deep link. First occurrence wins on a + * duplicate id — a one-time schedule reuses its bare schedule id for both its + * pending run and its last-run marker (mutually exclusive today, but the id's + * meaning shifts as the run completes). Until schedules load — or when the + * occurrence falls outside the current anchor/scope window — the id doesn't + * resolve, `selectedTask` stays `null`, and the param lingers harmlessly; the + * modal opens as soon as the id resolves. + */ + const taskById = useMemo(() => { + const byId = new Map() + for (const event of events) { + if (!byId.has(event.task.id)) byId.set(event.task.id, event) + } + return byId + }, [events]) + + const selectedTask = taskId ? (taskById.get(taskId)?.task ?? null) : null + + const openTask = useCallback((task: ScheduledTask) => setTaskId(task.id), [setTaskId]) + const closeTask = useCallback(() => setTaskId(null), [setTaskId]) + + /** + * Mutation-driven closes replace the URL instead of pushing — Back must not + * reopen a task the user just deleted/paused/resumed. Matches by schedule id + * prefix because occurrence ids are `scheduleId`, `scheduleId:`, + * or `scheduleId:last` (the ISO contains colons, so never split on `:`). + */ + const clearTaskIdForSchedule = useCallback( + (scheduleId: string) => + setTaskId( + (current) => + current !== null && (current === scheduleId || current.startsWith(`${scheduleId}:`)) + ? null + : current, + { history: 'replace' } + ), + [setTaskId] + ) const editSeedFor = useCallback( (task: ScheduledTask): TaskEditSeed | null => { @@ -185,7 +232,7 @@ export function useScheduledTasks({ const deleteTask = useCallback( (scheduleId: string) => { deleteSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -194,7 +241,7 @@ export function useScheduledTasks({ const deleteOccurrence = useCallback( (scheduleId: string, occurrence: Date) => { excludeOccurrence.mutate({ scheduleId, workspaceId, occurrence: occurrence.toISOString() }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -203,7 +250,7 @@ export function useScheduledTasks({ const pauseTask = useCallback( (scheduleId: string) => { disableSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -212,7 +259,7 @@ export function useScheduledTasks({ const resumeTask = useCallback( (scheduleId: string) => { resumeSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts index ab611047485..602d0835b78 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts @@ -1,4 +1,4 @@ -import { createParser, parseAsStringLiteral } from 'nuqs/server' +import { createParser, parseAsString, parseAsStringLiteral } from 'nuqs/server' const CALENDAR_SCOPES = ['day', 'week', 'month'] as const @@ -59,3 +59,20 @@ export const calendarUrlKeys = { history: 'replace', clearOnDefault: true, } as const + +/** + * The open task occurrence's id (`?taskId=`). The value is the occurrence id + * from `scheduleToTasks`: `scheduleId` (one-time), `scheduleId:` + * (recurring occurrence), or `scheduleId:last` (last-run marker). Nullable — + * a clean URL means no task modal is open. + */ +export const taskIdParam = { + key: 'taskId', + parser: parseAsString, +} as const + +/** Opening a task is a destination — Back closes it. */ +export const taskIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const 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 ed8be3af308..8d7f344e78c 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,19 +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' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type ApiKey, type ApiKeyScope, @@ -110,11 +106,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const defaultKeyType = isPersonalScope ? 'personal' 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 40c16c8ff99..402ed52191c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -15,14 +15,10 @@ 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' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type CopilotKey, useCopilotKeys, @@ -53,11 +49,7 @@ export function Copilot() { const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [createError, setCreateError] = useState(null) const filteredKeys = useMemo(() => { 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 4dd6dc08466..4c14c85d0ba 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,17 +6,13 @@ 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' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' @@ -31,11 +27,7 @@ export function CustomTools() { const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId) const deleteToolMutation = useDeleteCustomTool() - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [deletingTools, setDeletingTools] = useState>(() => new Set()) const [editingTool, setEditingTool] = useState(null) const [showAddForm, setShowAddForm] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 0200cdf45ab..0c48e4e981d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -5,7 +5,7 @@ import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn' import { formatRelativeTime } from '@sim/utils/formatting' import { ArrowRight, Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { type InboxStatusFilter, inboxTaskParsers, @@ -14,6 +14,7 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const STATUS_OPTIONS = [ { value: 'all', label: 'All statuses' }, @@ -26,9 +27,6 @@ const STATUS_OPTIONS = [ type StatusFilter = InboxStatusFilter -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const - const STATUS_BADGES: Record< string, { label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' } @@ -55,15 +53,8 @@ export function InboxTaskList() { * write is debounced. Filtering below is cheap in-memory over the loaded * tasks, so it reads the instant value too. */ - const setSearchTerm = useCallback( - (value: string) => { - const next = value.length > 0 ? value : null - setInboxFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setInboxFilters] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setInboxFilters({ search: value }, options) ) const { data: config } = useInboxConfig(workspaceId) 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 b6e5d635e16..281cdfca0c6 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 { debounce, useQueryState } from 'nuqs' +import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' @@ -24,13 +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' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' import { type McpServer, @@ -191,11 +188,7 @@ export function MCP() { const [showAddModal, setShowAddModal] = useState(false) const [editingServerId, setEditingServerId] = useState(null) - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [deletingServers, setDeletingServers] = useState>(() => new Set()) const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({ workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 25f0c59e857..1e58cfe641a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -1,24 +1,21 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn' import { Folder, Search, Workflow } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { - DEFAULT_RECENTLY_DELETED_SORT_COLUMN, - DEFAULT_RECENTLY_DELETED_SORT_DIRECTION, - RECENTLY_DELETED_SORT_COLUMNS, - type RecentlyDeletedSortColumn, type RecentlyDeletedTab, recentlyDeletedParsers, + recentlyDeletedSortParams, recentlyDeletedUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' @@ -33,6 +30,8 @@ import { useWorkspaceFileFolders, } from '@/hooks/queries/workspace-file-folders' import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useUrlSort } from '@/hooks/use-url-sort' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' @@ -67,18 +66,6 @@ function getResourceHref( } } -type SortColumn = 'deleted' | 'name' | 'type' - -interface SortConfig { - column: SortColumn - direction: 'asc' | 'desc' -} - -const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' } - -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const - const SORT_OPTIONS: ColumnOption[] = [ { id: 'deleted', label: 'Deleted' }, { id: 'name', label: 'Name' }, @@ -159,35 +146,26 @@ export function RecentlyDeleted() { const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) - const [ - { tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm }, - setRecentlyDeletedFilters, - ] = useQueryStates(recentlyDeletedParsers, recentlyDeletedUrlKeys) + const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates( + recentlyDeletedParsers, + recentlyDeletedUrlKeys + ) + + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort, + onClear, + } = useUrlSort(recentlyDeletedSortParams, recentlyDeletedUrlKeys) /** * 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. */ - const setSearchTerm = useCallback( - (value: string) => { - const trimmed = value.trim() - const next = trimmed.length > 0 ? trimmed : null - setRecentlyDeletedFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setRecentlyDeletedFilters] - ) - - const activeSort = useMemo( - () => - sortColumn === DEFAULT_RECENTLY_DELETED_SORT_COLUMN && - sortDirection === DEFAULT_RECENTLY_DELETED_SORT_DIRECTION - ? null - : { column: sortColumn, direction: sortDirection }, - [sortColumn, sortDirection] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setRecentlyDeletedFilters({ search: value }, options) ) const [restoringIds, setRestoringIds] = useState>(new Set()) @@ -301,15 +279,13 @@ export function RecentlyDeleted() { const filtered = useMemo(() => { let items = resources.filter((resource) => matchesActiveTab(resource, activeTab)) - if (urlSearchTerm.trim()) { - const normalized = urlSearchTerm.toLowerCase() + const normalized = urlSearchTerm.trim().toLowerCase() + if (normalized) { items = items.filter((r) => r.name.toLowerCase().includes(normalized)) } - const col = (activeSort ?? DEFAULT_SORT).column - const dir = (activeSort ?? DEFAULT_SORT).direction items.sort((a, b) => { let cmp = 0 - switch (col) { + switch (sortColumn) { case 'name': cmp = a.name.localeCompare(b.name) break @@ -320,24 +296,21 @@ export function RecentlyDeleted() { cmp = a.deletedAt.getTime() - b.deletedAt.getTime() break } - return dir === 'asc' ? cmp : -cmp + return sortDirection === 'asc' ? cmp : -cmp }) const itemIds = new Set(items.map((item) => item.id)) for (const [id, entry] of restoredItems) { if (itemIds.has(id)) continue if (!matchesActiveTab(entry.resource, activeTab)) continue - if ( - urlSearchTerm.trim() && - !entry.resource.name.toLowerCase().includes(urlSearchTerm.toLowerCase()) - ) { + if (normalized && !entry.resource.name.toLowerCase().includes(normalized)) { continue } items.splice(Math.min(entry.displayIndex, items.length), 0, entry.resource) } return items - }, [resources, activeTab, urlSearchTerm, activeSort, restoredItems]) + }, [resources, activeTab, urlSearchTerm, sortColumn, sortDirection, restoredItems]) const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0 @@ -427,17 +400,8 @@ export function RecentlyDeleted() { config={{ options: SORT_OPTIONS, active: activeSort, - onSort: (column, direction) => { - const sort = (RECENTLY_DELETED_SORT_COLUMNS as readonly string[]).includes(column) - ? (column as RecentlyDeletedSortColumn) - : DEFAULT_RECENTLY_DELETED_SORT_COLUMN - setRecentlyDeletedFilters({ sort, dir: direction }) - }, - onClear: () => - setRecentlyDeletedFilters({ - sort: DEFAULT_RECENTLY_DELETED_SORT_COLUMN, - dir: DEFAULT_RECENTLY_DELETED_SORT_DIRECTION, - }), + onSort, + onClear, }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index 01364745805..703359ce299 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,4 +1,5 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** Selectable resource-type tabs in the Recently Deleted view. */ export const RECENTLY_DELETED_TABS = [ @@ -15,30 +16,28 @@ export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number] /** Sortable columns for the deleted-items list. */ export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const -export type RecentlyDeletedSortColumn = (typeof RECENTLY_DELETED_SORT_COLUMNS)[number] - -const SORT_DIRECTIONS = ['asc', 'desc'] as const - -/** Default sort: most-recently-deleted first. */ -export const DEFAULT_RECENTLY_DELETED_SORT_COLUMN: RecentlyDeletedSortColumn = 'deleted' -export const DEFAULT_RECENTLY_DELETED_SORT_DIRECTION = 'desc' +/** + * Shared `sort` + `dir` params for the deleted-items list. Default sort: + * most-recently-deleted first. Consumed via `useUrlSort` in + * `recently-deleted.tsx`. + */ +export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_COLUMNS, { + column: 'deleted', + direction: 'desc', +}) /** * Co-located, typed URL query-param definitions for the Recently Deleted * settings view. * * - `tab` is the active resource-type filter. - * - `sort` / `dir` follow the shared sort convention. + * - `sort` / `dir` live in {@link recentlyDeletedSortParams} (shared sort + * convention). * - `search` is the 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. + * value; only its URL write is debounced via `useDebouncedSearchSetter`. */ export const recentlyDeletedParsers = { tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'), - sort: parseAsStringLiteral(RECENTLY_DELETED_SORT_COLUMNS).withDefault( - DEFAULT_RECENTLY_DELETED_SORT_COLUMN - ), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_RECENTLY_DELETED_SORT_DIRECTION), search: parseAsString.withDefault(''), } as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts index 2c9a281770a..8340051100e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -6,8 +6,9 @@ import { parseAsString } from 'nuqs/server' * 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. + * Consume via `useSettingsSearch` (`settings/components/use-settings-search`), + * which owns the debounced-write wiring — the input is controlled directly by + * the instant nuqs value; only the URL write is debounced. */ export const settingsSearchParam = { key: 'search', 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 fe5b40c7a0e..c7df7b3576c 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,7 +6,6 @@ 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, @@ -17,14 +16,11 @@ 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' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { @@ -367,11 +363,7 @@ export function SecretsManager() { const [newWorkspaceRows, setNewWorkspaceRows] = useState([ createEmptyEnvVar(), ]) - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) const [workspaceVars, setWorkspaceVars] = useState>({}) const [renamingKey, setRenamingKey] = useState(null) 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 35f48d0ab7e..a846be75638 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -6,7 +6,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryState } from 'nuqs' import { RoleLockTooltip, type WorkspaceRoleSource, @@ -21,11 +20,8 @@ import { MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { - settingsSearchParam, - settingsSearchUrlKeys, -} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { useCancelWorkspaceInvitation, @@ -74,11 +70,7 @@ export function Teammates() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const { data: permissions, isPending: permissionsLoading } = diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts new file mode 100644 index 00000000000..21563f749d4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -0,0 +1,22 @@ +'use client' + +import { debounce, useQueryState } from 'nuqs' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' + +/** + * The shared `?search=` binding for settings list search boxes (teammates, + * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). The + * value updates instantly (drives the controlled input and the in-memory + * filter); only the URL write is debounced. + */ +export function useSettingsSearch() { + return useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS), + }) +} 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 6e1aeef0aaf..9a3ac354664 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 @@ -25,7 +25,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' import { useParams } from 'next/navigation' -import { debounce, useQueryState } from 'nuqs' +import { 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' @@ -35,13 +35,10 @@ import { } 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' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' import { useCreateMcpServer } from '@/hooks/queries/mcp' @@ -887,11 +884,7 @@ export function WorkflowMcpServers() { const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId) const deleteServerMutation = useDeleteWorkflowMcpServer() - const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { - ...settingsSearchParam.parser, - ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [showAddModal, setShowAddModal] = useState(false) const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, { ...mcpServerIdParam.parser, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index d03949d9756..70a52dc1925 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -1,12 +1,12 @@ 'use client' -import { useCallback, useState } from 'react' +import { 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 { debounce, useQueryState } from 'nuqs' +import { 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' @@ -18,13 +18,12 @@ import { skillSearchUrlKeys, } from '@/app/workspace/[workspaceId]/skills/search-params' import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const logger = createLogger('SkillsSettings') const SKILLS_LABEL = 'Skills' -const SEARCH_DEBOUNCE_MS = 300 as const - interface SkillItemProps { name: string description: string @@ -89,19 +88,9 @@ export function Skills() { /** * 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. + * so it reads the instant value too. */ - const setSearchTerm = useCallback( - (value: string) => { - const next = value.length > 0 ? value : null - setSearchTermParam( - next, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setSearchTermParam] - ) + const setSearchTerm = useDebouncedSearchSetter(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 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 58844116eec..cb2b2914ea9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -1,6 +1,5 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' - -const SORT_DIRECTIONS = ['asc', 'desc'] as const +import { SORT_DIRECTIONS } from '@/lib/url-state' /** Default sort direction applied when a sort column is selected. */ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts index 461dc62f005..a1c75373053 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts @@ -1,4 +1,5 @@ -import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** Sortable table columns, matching the `Resource.Options` sort menu. */ export const TABLE_SORT_COLUMNS = [ @@ -10,21 +11,21 @@ export const TABLE_SORT_COLUMNS = [ 'updated', ] as const -export type TableSortColumn = (typeof TABLE_SORT_COLUMNS)[number] - -const SORT_DIRECTIONS = ['asc', 'desc'] as const - -/** Default sort: most-recently-updated first. */ -export const DEFAULT_TABLE_SORT_COLUMN: TableSortColumn = 'updated' -export const DEFAULT_TABLE_SORT_DIRECTION = 'desc' +/** + * Shared `sort` + `dir` params for the Tables list. Default sort: + * most-recently-updated first. Consumed via `useUrlSort` in `tables.tsx`. + */ +export const tablesSortParams = createSortParams(TABLE_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) /** * Co-located, typed URL query-param definitions for the Tables list. * * - `search` is the table 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). + * nuqs value; only its URL write is debounced via `useDebouncedSearchSetter`. + * - `sort` / `dir` live in {@link tablesSortParams} (shared sort convention). * - `rows` filters by row-count bucket; `owner` filters by creator id. Both are * multi-select arrays. * @@ -34,8 +35,6 @@ export const DEFAULT_TABLE_SORT_DIRECTION = 'desc' */ export const tablesParsers = { search: parseAsString.withDefault(''), - sort: parseAsStringLiteral(TABLE_SORT_COLUMNS).withDefault(DEFAULT_TABLE_SORT_COLUMN), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_SORT_DIRECTION), rows: parseAsArrayOf(parseAsString).withDefault([]), owner: parseAsArrayOf(parseAsString).withDefault([]), } as const diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index c88ae332b2b..4cc8ff85baa 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -7,9 +7,10 @@ import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -27,11 +28,8 @@ import { } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' import { - DEFAULT_TABLE_SORT_COLUMN, - DEFAULT_TABLE_SORT_DIRECTION, - TABLE_SORT_COLUMNS, - type TableSortColumn, tablesParsers, + tablesSortParams, tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -47,15 +45,14 @@ import { } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('Tables') -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const - const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, { id: 'columns', header: 'Columns' }, @@ -99,46 +96,26 @@ export function Tables() { const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) - const [ - { - search: urlSearchTerm, - sort: sortColumn, - dir: sortDirection, - rows: rowCountFilter, - owner: ownerFilter, - }, - setTableFilters, - ] = useQueryStates(tablesParsers, tablesUrlKeys) + const [{ search: urlSearchTerm, rows: rowCountFilter, owner: ownerFilter }, setTableFilters] = + useQueryStates(tablesParsers, tablesUrlKeys) + + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort, + onClear, + } = useUrlSort(tablesSortParams, tablesUrlKeys) /** * 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 trimmed = value.trim() - const next = trimmed.length > 0 ? trimmed : null - setTableFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setTableFilters] - ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, 300) - - /** - * 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_TABLE_SORT_COLUMN && sortDirection === DEFAULT_TABLE_SORT_DIRECTION - ? null - : { column: sortColumn, direction: sortDirection }, - [sortColumn, sortDirection] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setTableFilters({ search: value }, options) ) + const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) const setRowCountFilter = useCallback( (next: string[]) => setTableFilters({ rows: next }), @@ -168,9 +145,8 @@ export function Tables() { } = useContextMenu() const processedTables = useMemo(() => { - let result = debouncedSearchTerm - ? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - : tables + const query = debouncedSearchTerm.trim().toLowerCase() + let result = query ? tables.filter((t) => t.name.toLowerCase().includes(query)) : tables if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -183,11 +159,9 @@ export function Tables() { if (ownerFilter.length > 0) { result = result.filter((t) => ownerFilter.includes(t.createdBy)) } - const col = activeSort?.column ?? 'updated' - const dir = activeSort?.direction ?? 'desc' return [...result].sort((a, b) => { let cmp = 0 - switch (col) { + switch (sortColumn) { case 'name': cmp = a.name.localeCompare(b.name) break @@ -210,9 +184,9 @@ export function Tables() { break } } - return dir === 'asc' ? cmp : -cmp + return sortDirection === 'asc' ? cmp : -cmp }) - }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members]) + }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, sortColumn, sortDirection, members]) const rows: ResourceRow[] = useMemo( () => @@ -277,19 +251,10 @@ export function Tables() { { id: 'updated', label: 'Last Updated' }, ], active: activeSort, - onSort: (column, direction) => { - const sort = (TABLE_SORT_COLUMNS as readonly string[]).includes(column) - ? (column as TableSortColumn) - : DEFAULT_TABLE_SORT_COLUMN - setTableFilters({ sort, dir: direction }) - }, - onClear: () => - setTableFilters({ - sort: DEFAULT_TABLE_SORT_COLUMN, - dir: DEFAULT_TABLE_SORT_DIRECTION, - }), + onSort, + onClear, }), - [activeSort, setTableFilters] + [activeSort, onSort, onClear] ) const rowCountDisplayLabel = useMemo(() => { diff --git a/apps/sim/hooks/use-debounced-search-setter.ts b/apps/sim/hooks/use-debounced-search-setter.ts new file mode 100644 index 00000000000..75d7af39668 --- /dev/null +++ b/apps/sim/hooks/use-debounced-search-setter.ts @@ -0,0 +1,38 @@ +'use client' + +import { useCallback, useRef } from 'react' +import { debounce, type Options } from 'nuqs' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' + +type SearchWrite = (value: string | null, options?: Options) => void + +interface UseDebouncedSearchSetterOptions { + debounceMs?: number +} + +/** + * The canonical setter for a nuqs-backed search param (see + * `.claude/rules/sim-url-state.md`, "Debounced text inputs"). The input stays + * controlled by the instant nuqs value; only the URL write is debounced. + * Clearing (or a whitespace-only value) writes `null` immediately so the param + * strips without lingering. The RAW value is written — never a trimmed one, + * which would eat the user's trailing space mid-typing; consumers trim on read. + * + * Grouped params: `useDebouncedSearchSetter((v, o) => setFilters({ search: v }, o))`. + * Single param: pass the `useQueryState` setter directly. + */ +export function useDebouncedSearchSetter( + write: SearchWrite, + { debounceMs = SEARCH_DEBOUNCE_MS }: UseDebouncedSearchSetterOptions = {} +): (value: string) => void { + const writeRef = useRef(write) + writeRef.current = write + + return useCallback( + (value: string) => { + const next = value.trim().length > 0 ? value : null + writeRef.current(next, next === null ? undefined : { limitUrlUpdates: debounce(debounceMs) }) + }, + [debounceMs] + ) +} diff --git a/apps/sim/hooks/use-url-sort.ts b/apps/sim/hooks/use-url-sort.ts new file mode 100644 index 00000000000..502c505e1b0 --- /dev/null +++ b/apps/sim/hooks/use-url-sort.ts @@ -0,0 +1,80 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { type Options, useQueryStates } from 'nuqs' +import type { DefaultedSortParams, NullableSortParams, SortDirection } from '@/lib/url-state' + +/** The nullable active-sort shape the shared sort menu (`SortConfig`) consumes. */ +export interface ActiveSort { + column: string + direction: SortDirection +} + +export interface UseUrlSortReturn { + /** Raw resolved column — feed this to query keys / comparators. */ + sort: Sort + /** Raw resolved direction. */ + dir: Dir + /** `null` when the list shows no active sort; plugs into `SortConfig.active`. */ + activeSort: ActiveSort | null + /** Validates the column against the param's literal set; no-op on unknown ids. */ + onSort: (column: string, direction: SortDirection) => void + /** Defaulted mode writes the defaults back (stripped by `clearOnDefault`); nullable mode strips both params. */ + onClear: () => void +} + +/** + * Binds a `createSortParams` definition (from `@/lib/url-state/sort-params`) + * to the URL and derives the canonical sort wiring for a sortable list: + * defaulted mode collapses an explicit default selection to "no active sort", + * nullable mode treats `null` params as the distinct unsorted state. Pass the + * feature's shared url-keys object (e.g. `{ history: 'replace', shallow: true, + * clearOnDefault: true }`) as `options`. + */ +export function useUrlSort( + params: DefaultedSortParams, + options?: Options +): UseUrlSortReturn +export function useUrlSort( + params: NullableSortParams, + options?: Options +): UseUrlSortReturn +export function useUrlSort( + params: DefaultedSortParams | NullableSortParams, + options: Options = {} +): UseUrlSortReturn { + const [values, setValues] = useQueryStates( + params.parsers as NullableSortParams['parsers'], + options + ) + const sort = values.sort ?? null + const dir = values.dir ?? null + const sortDefault = params.default + + const activeSort = useMemo(() => { + if (sortDefault !== null) { + return sort === sortDefault.column && dir === sortDefault.direction + ? null + : { column: sort as string, direction: dir as SortDirection } + } + return sort !== null && dir !== null ? { column: sort, direction: dir } : null + }, [sortDefault, sort, dir]) + + const onSort = useCallback( + (column: string, direction: SortDirection) => { + if (!(params.columns as readonly string[]).includes(column)) return + void setValues({ sort: column as C, dir: direction }) + }, + [params, setValues] + ) + + const onClear = useCallback(() => { + void setValues( + sortDefault !== null + ? { sort: sortDefault.column, dir: sortDefault.direction } + : { sort: null, dir: null } + ) + }, [sortDefault, setValues]) + + return { sort, dir, activeSort, onSort, onClear } +} diff --git a/apps/sim/lib/url-state/constants.ts b/apps/sim/lib/url-state/constants.ts new file mode 100644 index 00000000000..f37b3f515cf --- /dev/null +++ b/apps/sim/lib/url-state/constants.ts @@ -0,0 +1,6 @@ +/** + * Shared debounce window for search-param URL writes across list surfaces. + * The input is always controlled by the instant nuqs value; only the URL + * write (and any query/filter consumer via `useDebounce`) waits this long. + */ +export const SEARCH_DEBOUNCE_MS = 300 as const diff --git a/apps/sim/lib/url-state/index.ts b/apps/sim/lib/url-state/index.ts new file mode 100644 index 00000000000..2559ea7a8dc --- /dev/null +++ b/apps/sim/lib/url-state/index.ts @@ -0,0 +1,9 @@ +export { SEARCH_DEBOUNCE_MS } from '@/lib/url-state/constants' +export { + createSortParams, + type DefaultedSortParams, + type NullableSortParams, + SORT_DIRECTIONS, + type SortDefault, + type SortDirection, +} from '@/lib/url-state/sort-params' diff --git a/apps/sim/lib/url-state/sort-params.ts b/apps/sim/lib/url-state/sort-params.ts new file mode 100644 index 00000000000..eff86afe6c7 --- /dev/null +++ b/apps/sim/lib/url-state/sort-params.ts @@ -0,0 +1,72 @@ +import { parseAsStringLiteral, type SingleParserBuilder } from 'nuqs/server' + +/** The two sort directions every sortable list shares. */ +export const SORT_DIRECTIONS = ['asc', 'desc'] as const + +export type SortDirection = (typeof SORT_DIRECTIONS)[number] + +/** The list's default ordering — what a clean URL means. */ +export interface SortDefault { + column: C + direction: SortDirection +} + +type DefaultedParser = ReturnType['withDefault']> + +/** + * Sort params whose defaults match the list's server/default ordering. A clean + * URL means the default sort; explicitly selecting the default collapses back + * to a clean URL (`clearOnDefault`), so "no active sort" and "default sort" + * are the same state. + */ +export interface DefaultedSortParams { + columns: readonly C[] + default: SortDefault + parsers: { sort: DefaultedParser; dir: DefaultedParser } +} + +/** + * Nullable sort params for lists where "no active sort" is behaviorally + * distinct from explicitly sorting by the fallback column (e.g. files: with no + * sort, files order by updated/desc but folders by name/asc). The params carry + * no defaults, so an explicit selection always persists in the URL and + * clearing writes `null` to strip both. + */ +export interface NullableSortParams { + columns: readonly C[] + default: null + parsers: { sort: SingleParserBuilder; dir: SingleParserBuilder } +} + +/** + * Builds the canonical `sort` + `dir` URL param pair for a sortable list (see + * `.claude/rules/sim-url-state.md`, "Sort convention"). Pass `defaultSort` + * when the list has a fixed default ordering (the common case); omit it for + * the nullable mode where "no active sort" is a distinct state. Consume with + * `useUrlSort` from `@/hooks/use-url-sort`. + */ +export function createSortParams( + columns: readonly C[], + defaultSort: SortDefault> +): DefaultedSortParams +export function createSortParams( + columns: readonly C[] +): NullableSortParams +export function createSortParams( + columns: readonly C[], + defaultSort?: SortDefault> +): DefaultedSortParams | NullableSortParams { + const sort = parseAsStringLiteral(columns) + const dir = parseAsStringLiteral(SORT_DIRECTIONS) + if (defaultSort) { + return { + columns, + default: defaultSort, + parsers: { + sort: sort.withDefault(defaultSort.column), + dir: dir.withDefault(defaultSort.direction), + }, + } + } + return { columns, default: null, parsers: { sort, dir } } +} From 1b7aaab8b7335473222a068109596e9ccca9aead Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:03:48 -0700 Subject: [PATCH 2/7] =?UTF-8?q?improvement(url-state):=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20cancel=20pending=20deep-link=20on=20click,=20void?= =?UTF-8?q?=20settings=20search=20setter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 6 ++++++ .../settings/components/api-keys/api-keys.tsx | 2 +- .../settings/components/copilot/copilot.tsx | 2 +- .../components/custom-tools/custom-tools.tsx | 2 +- .../[workspaceId]/settings/components/mcp/mcp.tsx | 2 +- .../components/secrets-manager/secrets-manager.tsx | 2 +- .../settings/components/teammates/teammates.tsx | 2 +- .../settings/components/use-settings-search.ts | 13 ++++++++++--- .../workflow-mcp-servers/workflow-mcp-servers.tsx | 2 +- 9 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 9d0c5b743e2..a14717a1e1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -422,6 +422,12 @@ export default function Logs() { */ const handleLogClick = useCallback( (rowId: string) => { + /** + * An explicit click supersedes an in-flight deep-link resolution — + * otherwise the resolved row would open over the user's selection and + * leave the URL pointing at a different run than the panel shows. + */ + setPendingExecutionId(null) const opens = !(selectedLogIdRef.current === rowId && isSidebarOpenRef.current) dispatch({ type: 'TOGGLE_LOG', logId: rowId }) if (opens) { 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 8d7f344e78c..d434f922ce2 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 @@ -185,7 +185,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { void setSearchTerm(value), + onChange: setSearchTerm, 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 402ed52191c..c746dd62828 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -124,7 +124,7 @@ export function Copilot() { void setSearchTerm(value), + onChange: setSearchTerm, 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 4c14c85d0ba..2a5e789da06 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 @@ -109,7 +109,7 @@ export function CustomTools() { void setSearchTerm(value), + onChange: setSearchTerm, 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 281cdfca0c6..a7888d5f7dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -619,7 +619,7 @@ export function MCP() { void setSearchTerm(value), + onChange: setSearchTerm, placeholder: 'Search MCPs...', }} actions={ 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 c7df7b3576c..3e18f61ff1e 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 @@ -952,7 +952,7 @@ export function SecretsManager() { scrollContainerRef={scrollContainerRef} search={{ value: searchTerm, - onChange: (value) => void setSearchTerm(value), + onChange: setSearchTerm, placeholder: 'Search secrets...', }} actions={[ 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 a846be75638..170c5c9ec2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -169,7 +169,7 @@ export function Teammates() { void setSearchTerm(value), + onChange: setSearchTerm, placeholder: 'Search teammates...', }} actions={ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts index 21563f749d4..474172a3924 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -1,5 +1,6 @@ 'use client' +import { useCallback } from 'react' import { debounce, useQueryState } from 'nuqs' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { @@ -11,12 +12,18 @@ import { * The shared `?search=` binding for settings list search boxes (teammates, * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). The * value updates instantly (drives the controlled input and the in-memory - * filter); only the URL write is debounced. + * filter); only the URL write is debounced. The setter is `void`-returning so + * it passes straight to `SettingsPanel`'s `search.onChange`. */ -export function useSettingsSearch() { - return useQueryState(settingsSearchParam.key, { +export function useSettingsSearch(): [string, (value: string) => void] { + const [searchTerm, setSearchTermParam] = useQueryState(settingsSearchParam.key, { ...settingsSearchParam.parser, ...settingsSearchUrlKeys, limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS), }) + const setSearchTerm = useCallback( + (value: string) => void setSearchTermParam(value), + [setSearchTermParam] + ) + return [searchTerm, setSearchTerm] } 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 9a3ac354664..b5c46b36462 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 @@ -970,7 +970,7 @@ export function WorkflowMcpServers() { void setSearchTerm(value), + onChange: setSearchTerm, placeholder: 'Search servers...', }} actions={actions} From 82fc9fbc1308229a3aca88ccb9455c857876006e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:14:38 -0700 Subject: [PATCH 3/7] =?UTF-8?q?fix(url-state):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20trim-on-read=20filters,=20replace-on-close=20for=20?= =?UTF-8?q?task=20modal,=20unified=20executionId=20write=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/[workspaceId]/files/files.tsx | 14 ++++---- .../[workspaceId]/knowledge/utils/sort.ts | 2 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 34 ++++++++++++------- .../hooks/use-scheduled-tasks.ts | 3 +- .../inbox-task-list/inbox-task-list.tsx | 2 +- .../workspace/[workspaceId]/skills/skills.tsx | 2 +- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index e24996ae1e2..c6bcecde52d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -388,10 +388,9 @@ export function Files() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const searched = debouncedSearchTerm - ? siblings.filter((folder) => - folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) - ) + const needle = debouncedSearchTerm.trim().toLowerCase() + const searched = needle + ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings const col = activeSort?.column ?? 'name' const dir = activeSort?.direction ?? 'asc' @@ -409,11 +408,10 @@ export function Files() { }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) const filteredFiles = useMemo(() => { - let result = debouncedSearchTerm + const needle = debouncedSearchTerm.trim().toLowerCase() + let result = needle ? files.filter( - (f) => - (f.folderId ?? null) === currentFolderId && - f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + (f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle) ) : files.filter((f) => (f.folderId ?? null) === currentFolderId) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts index 76bc770e972..5c5df5059aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts @@ -48,7 +48,7 @@ export function filterKnowledgeBases( return knowledgeBases } - const query = searchQuery.toLowerCase() + const query = searchQuery.trim().toLowerCase() return knowledgeBases.filter( (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index a14717a1e1d..bde76fd7d4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -415,6 +415,20 @@ export default function Logs() { } }, []) + /** + * The single write path for user-driven `executionId` changes. Cancels any + * in-flight deep-link resolution first — an explicit interaction supersedes + * it, otherwise the resolved row would open over the user's selection and + * leave the URL pointing at a different run than the panel shows. + */ + const writeExecutionId = useCallback( + (value: string | null) => { + setPendingExecutionId(null) + void setExecutionId(value, executionIdWriteOptions) + }, + [setExecutionId] + ) + /** * Mirrors the reducer's TOGGLE_LOG branch: clicking the already-open row * closes the sidebar (strip `executionId`); any other click opens the row @@ -422,22 +436,16 @@ export default function Logs() { */ const handleLogClick = useCallback( (rowId: string) => { - /** - * An explicit click supersedes an in-flight deep-link resolution — - * otherwise the resolved row would open over the user's selection and - * leave the URL pointing at a different run than the panel shows. - */ - setPendingExecutionId(null) const opens = !(selectedLogIdRef.current === rowId && isSidebarOpenRef.current) dispatch({ type: 'TOGGLE_LOG', logId: rowId }) if (opens) { const log = logsRef.current.find((l) => l.id === rowId) - setExecutionId(log?.executionId ?? null, executionIdWriteOptions) + writeExecutionId(log?.executionId ?? null) } else { - setExecutionId(null, executionIdWriteOptions) + writeExecutionId(null) } }, - [setExecutionId] + [writeExecutionId] ) const handleNavigateNext = useCallback(() => { @@ -448,7 +456,7 @@ export default function Logs() { shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: nextLog.id }) if (isSidebarOpenRef.current) { - setExecutionId(nextLog.executionId ?? null, executionIdWriteOptions) + writeExecutionId(nextLog.executionId ?? null) } } }, [setExecutionId]) @@ -460,14 +468,14 @@ export default function Logs() { shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: prevLog.id }) if (isSidebarOpenRef.current) { - setExecutionId(prevLog.executionId ?? null, executionIdWriteOptions) + writeExecutionId(prevLog.executionId ?? null) } } }, [setExecutionId]) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) - setExecutionId(null, executionIdWriteOptions) + writeExecutionId(null) activeLogTabRef.current = 'overview' }, [setExecutionId]) @@ -699,7 +707,7 @@ export default function Logs() { const handleNavigateNextEvent = useEffectEvent(handleNavigateNext) const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev) const writeExecutionIdEvent = useEffectEvent((value: string | null) => { - setExecutionId(value, executionIdWriteOptions) + writeExecutionId(value) }) useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts index 4b00d1247fe..c735367942b 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts @@ -169,7 +169,8 @@ export function useScheduledTasks({ const selectedTask = taskId ? (taskById.get(taskId)?.task ?? null) : null const openTask = useCallback((task: ScheduledTask) => setTaskId(task.id), [setTaskId]) - const closeTask = useCallback(() => setTaskId(null), [setTaskId]) + /** Closing replaces the URL — Back should leave the calendar, not reopen the modal. */ + const closeTask = useCallback(() => setTaskId(null, { history: 'replace' }), [setTaskId]) /** * Mutation-driven closes replace the URL instead of pushing — Back must not diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 0c48e4e981d..5d00c3afa5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -65,7 +65,7 @@ export function InboxTaskList() { const filteredTasks = useMemo(() => { if (!tasksData?.tasks) return [] if (!searchTerm.trim()) return tasksData.tasks - const term = searchTerm.toLowerCase() + const term = searchTerm.trim().toLowerCase() return tasksData.tasks.filter( (t) => t.subject?.toLowerCase().includes(term) || diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 70a52dc1925..d045bf88b1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -97,7 +97,7 @@ export function Skills() { const filteredSkills = skills.filter((s) => { if (!searchTerm.trim()) return true - const searchLower = searchTerm.toLowerCase() + const searchLower = searchTerm.trim().toLowerCase() return ( s.name.toLowerCase().includes(searchLower) || s.description.toLowerCase().includes(searchLower) From e5d8cf5469fb174c80d64d4be8e1165760f5de77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:32:26 -0700 Subject: [PATCH 4/7] =?UTF-8?q?improvement(url-state):=20final=20audit=20p?= =?UTF-8?q?olish=20=E2=80=94=20consistent=20handler=20deps,=20document=20s?= =?UTF-8?q?chedules-refetch=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 6 +++--- .../scheduled-tasks/hooks/use-scheduled-tasks.ts | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index bde76fd7d4a..9274a26e23a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -459,7 +459,7 @@ export default function Logs() { writeExecutionId(nextLog.executionId ?? null) } } - }, [setExecutionId]) + }, [writeExecutionId]) const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current @@ -471,13 +471,13 @@ export default function Logs() { writeExecutionId(prevLog.executionId ?? null) } } - }, [setExecutionId]) + }, [writeExecutionId]) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) writeExecutionId(null) activeLogTabRef.current = 'overview' - }, [setExecutionId]) + }, [writeExecutionId]) /** * Strip the `tab` param whenever the detail panel transitions from open to diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts index c735367942b..60a18f7572c 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts @@ -156,7 +156,10 @@ export function useScheduledTasks({ * meaning shifts as the run completes). Until schedules load — or when the * occurrence falls outside the current anchor/scope window — the id doesn't * resolve, `selectedTask` stays `null`, and the param lingers harmlessly; the - * modal opens as soon as the id resolves. + * modal opens as soon as the id resolves. Stability of an OPEN modal relies on + * the schedules query not refetching in the background (no refetchInterval; + * the app-wide QueryClient disables refetchOnWindowFocus) — a mid-edit refetch + * that regenerates occurrence ids would close the modal and drop the draft. */ const taskById = useMemo(() => { const byId = new Map() From 48106738db0a0fef62202c248ee14edd44a7c042 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:38:07 -0700 Subject: [PATCH 5/7] improvement(url-state): compose useSettingsSearch on the shared setter, trim chunk query read --- .../knowledge/[id]/[documentId]/document.tsx | 7 ++++--- .../components/use-settings-search.ts | 21 ++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index e9d41560683..25bf4c77092 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -157,7 +157,8 @@ export function Document({ (value, options) => void setDocumentParams({ search: value }, options), { debounceMs: CHUNK_SEARCH_DEBOUNCE_MS } ) - const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS) + /** Raw URL value drives the input; the chunk search query always sees it trimmed. */ + const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim() const [enabledFilter, setEnabledFilter] = useState([]) const { activeSort, @@ -200,7 +201,7 @@ export function Document({ search: debouncedSearchQuery, }, { - enabled: Boolean(debouncedSearchQuery.trim()), + enabled: Boolean(debouncedSearchQuery), } ) @@ -230,7 +231,7 @@ export function Document({ const saveStatusRef = useRef('idle') saveStatusRef.current = saveStatus - const isSearching = debouncedSearchQuery.trim().length > 0 + const isSearching = debouncedSearchQuery.length > 0 const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0 const SEARCH_PAGE_SIZE = 50 const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts index 474172a3924..2da76cad0ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -1,29 +1,26 @@ 'use client' -import { useCallback } from 'react' -import { debounce, useQueryState } from 'nuqs' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { useQueryState } from 'nuqs' import { settingsSearchParam, settingsSearchUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** * The shared `?search=` binding for settings list search boxes (teammates, - * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). The - * value updates instantly (drives the controlled input and the in-memory - * filter); only the URL write is debounced. The setter is `void`-returning so - * it passes straight to `SettingsPanel`'s `search.onChange`. + * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). + * Composes `useDebouncedSearchSetter`, so it carries the canonical semantics: + * the value updates instantly (drives the controlled input and the in-memory + * filter), non-empty URL writes are debounced, and clearing (or a + * whitespace-only value) strips the param immediately. The setter is + * `void`-returning so it passes straight to `SettingsPanel`'s `search.onChange`. */ export function useSettingsSearch(): [string, (value: string) => void] { const [searchTerm, setSearchTermParam] = useQueryState(settingsSearchParam.key, { ...settingsSearchParam.parser, ...settingsSearchUrlKeys, - limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS), }) - const setSearchTerm = useCallback( - (value: string) => void setSearchTermParam(value), - [setSearchTermParam] - ) + const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) return [searchTerm, setSearchTerm] } From a32ad5625253f5ad156d49916387a4a03054b5f1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:53:05 -0700 Subject: [PATCH 6/7] chore(skills): sync url-state skill edits through the canonical SKILL.md --- .agents/skills/you-might-not-need-url-state/SKILL.md | 9 +++++++-- .cursor/commands/you-might-not-need-url-state.md | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.agents/skills/you-might-not-need-url-state/SKILL.md b/.agents/skills/you-might-not-need-url-state/SKILL.md index 0c1296a5213..561829e4152 100644 --- a/.agents/skills/you-might-not-need-url-state/SKILL.md +++ b/.agents/skills/you-might-not-need-url-state/SKILL.md @@ -16,6 +16,10 @@ User arguments: $ARGUMENTS Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -33,14 +37,15 @@ Read these before analyzing: 3. **`window.history.replaceState`/`pushState`** to mutate a param. 4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it. 5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`). -6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## Steps 1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines 2. Analyze the specified scope for the anti-patterns listed above 3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state -4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. diff --git a/.cursor/commands/you-might-not-need-url-state.md b/.cursor/commands/you-might-not-need-url-state.md index 60e96ca0bcf..330835125fa 100644 --- a/.cursor/commands/you-might-not-need-url-state.md +++ b/.cursor/commands/you-might-not-need-url-state.md @@ -10,6 +10,10 @@ User arguments: $ARGUMENTS Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -27,14 +31,15 @@ Read these before analyzing: 3. **`window.history.replaceState`/`pushState`** to mutate a param. 4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it. 5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`). -6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## Steps 1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines 2. Analyze the specified scope for the anti-patterns listed above 3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state -4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. From 9cea09d7d23e22bc3eec74ce9b929bda77ca6515 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:56:03 -0700 Subject: [PATCH 7/7] fix(url-state): reset chunk page in the same write as a search change --- .../[workspaceId]/knowledge/[id]/[documentId]/document.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 25bf4c77092..6a875d4a824 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -151,10 +151,12 @@ export function Document({ /** * The input is controlled directly by the instant nuqs value; only the URL * write is debounced. The chunk search query below reads a debounced value so - * it doesn't refetch on every keystroke. + * it doesn't refetch on every keystroke. Changing the search resets `page` in + * the same write — a search started from a later page must land on the first + * page of matches, and a shared search link must open there too. */ const handleSearchChange = useDebouncedSearchSetter( - (value, options) => void setDocumentParams({ search: value }, options), + (value, options) => void setDocumentParams({ search: value, page: null }, options), { debounceMs: CHUNK_SEARCH_DEBOUNCE_MS } ) /** Raw URL value drives the input; the chunk search query always sees it trimmed. */