improvement(url-state): shared sort/search url-state helpers + task and log selection deep links (#5652)

* improvement(url-state): shared sort/search url-state helpers + task and log selection deep links

* improvement(url-state): audit fixes — cancel pending deep-link on click, void settings search setter

* fix(url-state): review fixes — trim-on-read filters, replace-on-close for task modal, unified executionId write path

* improvement(url-state): final audit polish — consistent handler deps, document schedules-refetch invariant

* improvement(url-state): compose useSettingsSearch on the shared setter, trim chunk query read

* chore(skills): sync url-state skill edits through the canonical SKILL.md

* fix(url-state): reset chunk page in the same write as a search change
This commit is contained in:
Waleed
2026-07-13 18:08:36 -07:00
committed by GitHub
parent 5dc8d8f097
commit 3d67b5045b
42 changed files with 777 additions and 593 deletions
@@ -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 `<Suspense>` 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.
@@ -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 `<Suspense>` 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.
+38 -24
View File
@@ -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'
```typescript
import { debounce } from 'nuqs'
// Search inside a grouped useQueryStates — the group's discrete filters keep immediate writes:
const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ search: value }, options))
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]
)
```
// Standalone single param — pass the useQueryState setter directly:
const setSearch = useDebouncedSearchSetter(setSearchParam)
- **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).
// Non-default window (e.g. files' 200ms):
const setSearch = useDebouncedSearchSetter(write, { debounceMs: 200 })
```
- **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)
@@ -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 `<Suspense>` 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.
@@ -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<string, number>()
@@ -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'
/>
</div>
@@ -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'
/>
</div>
@@ -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 }),
@@ -403,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'
@@ -424,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)
@@ -1745,13 +1728,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 =
@@ -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',
@@ -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({
@@ -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,25 @@ 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. 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, page: null }, options),
{ debounceMs: 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<string[]>([])
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'),
@@ -181,7 +203,7 @@ export function Document({
search: debouncedSearchQuery,
},
{
enabled: Boolean(debouncedSearchQuery.trim()),
enabled: Boolean(debouncedSearchQuery),
}
)
@@ -211,7 +233,7 @@ export function Document({
const saveStatusRef = useRef<SaveStatus>('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)
@@ -591,7 +613,7 @@ export function Document({
const searchConfig: SearchConfig | undefined = isCompleted
? {
value: searchQuery,
onChange: (value: string) => setSearchQuery(value),
onChange: handleSearchChange,
placeholder: 'Search chunks...',
}
: undefined
@@ -861,16 +883,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(() => {
@@ -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
/**
@@ -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)}
>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
<SearchHighlight text={doc.filename} searchQuery={highlightQuery} />
</FloatingOverflowText>
</span>
),
@@ -1166,7 +1154,7 @@ export function KnowledgeBase({
},
}
}),
[documents, tagDefinitions, searchQuery]
[documents, tagDefinitions, highlightQuery]
)
if (error && !knowledgeBase) {
@@ -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. */
@@ -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(
@@ -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([]),
@@ -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)
)
@@ -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(
@@ -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<string | null>(() => 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<WorkflowLogSummary[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const isSidebarOpenRef = useRef(false)
const shouldScrollIntoViewRef = useRef(false)
const resourceTableRef = useRef<ResourceTableHandle>(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,69 @@ export default function Logs() {
}
}, [])
const handleLogClick = useCallback((rowId: string) => {
dispatch({ type: 'TOGGLE_LOG', logId: rowId })
}, [])
/**
* 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
* (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)
writeExecutionId(log?.executionId ?? null)
} else {
writeExecutionId(null)
}
},
[writeExecutionId]
)
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) {
writeExecutionId(nextLog.executionId ?? null)
}
}
}, [])
}, [writeExecutionId])
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) {
writeExecutionId(prevLog.executionId ?? null)
}
}
}, [])
}, [writeExecutionId])
const handleCloseSidebar = useCallback(() => {
dispatch({ type: 'CLOSE_SIDEBAR' })
writeExecutionId(null)
activeLogTabRef.current = 'overview'
}, [])
}, [writeExecutionId])
/**
* Strip the `tab` param whenever the detail panel transitions from open to
@@ -663,6 +706,9 @@ export default function Logs() {
const handleNavigateNextEvent = useEffectEvent(handleNavigateNext)
const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev)
const writeExecutionIdEvent = useEffectEvent((value: string | null) => {
writeExecutionId(value)
})
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -697,7 +743,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 +1073,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<SearchConfig>(
@@ -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
/**
@@ -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<ScheduledTask | null>(null)
const [taskId, setTaskId] = useQueryState(taskIdParam.key, {
...taskIdParam.parser,
...taskIdUrlKeys,
})
const events = useMemo(() => {
const now = new Date()
@@ -139,8 +149,49 @@ 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. 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<string, CalendarEvent>()
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])
/** 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
* reopen a task the user just deleted/paused/resumed. Matches by schedule id
* prefix because occurrence ids are `scheduleId`, `scheduleId:<runAt ISO>`,
* 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 +236,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 +245,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 +254,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 +263,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]
@@ -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:<runAt ISO>`
* (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
@@ -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<ApiKey | null>(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'
@@ -193,7 +185,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
@@ -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<CopilotKey | null>(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<string | null>(null)
const filteredKeys = useMemo(() => {
@@ -132,7 +124,7 @@ export function Copilot() {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
@@ -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<Set<string>>(() => new Set())
const [editingTool, setEditingTool] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
@@ -117,7 +109,7 @@ export function CustomTools() {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search tools...',
disabled: isLoading,
}}
@@ -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)
@@ -74,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) ||
@@ -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<string | null>(null)
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
...settingsSearchParam.parser,
...settingsSearchUrlKeys,
limitUrlUpdates: debounce(300),
})
const [searchTerm, setSearchTerm] = useSettingsSearch()
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({
workspaceId,
@@ -626,7 +619,7 @@ export function MCP() {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search MCPs...',
}}
actions={
@@ -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<SortConfig | null>(
() =>
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<Set<string>>(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,
}}
/>
</div>
@@ -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
@@ -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',
@@ -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<UIEnvironmentVariable[]>([
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<Record<string, string>>({})
const [renamingKey, setRenamingKey] = useState<string | null>(null)
@@ -960,7 +952,7 @@ export function SecretsManager() {
scrollContainerRef={scrollContainerRef}
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search secrets...',
}}
actions={[
@@ -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 } =
@@ -177,7 +169,7 @@ export function Teammates() {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search teammates...',
}}
actions={
@@ -0,0 +1,26 @@
'use client'
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).
* 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,
})
const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
return [searchTerm, setSearchTerm]
}
@@ -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,
@@ -977,7 +970,7 @@ export function WorkflowMcpServers() {
<SettingsPanel
search={{
value: searchTerm,
onChange: (value) => void setSearchTerm(value),
onChange: setSearchTerm,
placeholder: 'Search servers...',
}}
actions={actions}
@@ -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,26 +88,16 @@ 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
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)
@@ -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'
@@ -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
@@ -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<TableDefinition | null>(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(() => {
@@ -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]
)
}
+80
View File
@@ -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<Sort, Dir> {
/** 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<C extends string>(
params: DefaultedSortParams<C>,
options?: Options
): UseUrlSortReturn<C, SortDirection>
export function useUrlSort<C extends string>(
params: NullableSortParams<C>,
options?: Options
): UseUrlSortReturn<C | null, SortDirection | null>
export function useUrlSort<C extends string>(
params: DefaultedSortParams<C> | NullableSortParams<C>,
options: Options = {}
): UseUrlSortReturn<C | null, SortDirection | null> {
const [values, setValues] = useQueryStates(
params.parsers as NullableSortParams<C>['parsers'],
options
)
const sort = values.sort ?? null
const dir = values.dir ?? null
const sortDefault = params.default
const activeSort = useMemo<ActiveSort | null>(() => {
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 }
}
+6
View File
@@ -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
+9
View File
@@ -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'
+72
View File
@@ -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<C extends string> {
column: C
direction: SortDirection
}
type DefaultedParser<T extends string> = ReturnType<SingleParserBuilder<T>['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<C extends string> {
columns: readonly C[]
default: SortDefault<C>
parsers: { sort: DefaultedParser<C>; dir: DefaultedParser<SortDirection> }
}
/**
* 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<C extends string> {
columns: readonly C[]
default: null
parsers: { sort: SingleParserBuilder<C>; dir: SingleParserBuilder<SortDirection> }
}
/**
* 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<const C extends string>(
columns: readonly C[],
defaultSort: SortDefault<NoInfer<C>>
): DefaultedSortParams<C>
export function createSortParams<const C extends string>(
columns: readonly C[]
): NullableSortParams<C>
export function createSortParams<const C extends string>(
columns: readonly C[],
defaultSort?: SortDefault<NoInfer<C>>
): DefaultedSortParams<C> | NullableSortParams<C> {
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 } }
}