diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a841f40098c..1c45eae5416 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -3,6 +3,8 @@ paths: - "apps/sim/app/**/*.tsx" - "apps/sim/app/**/*.ts" - "apps/sim/app/**/search-params.ts" + - "apps/sim/ee/**/*.tsx" + - "apps/sim/ee/**/*.ts" --- # URL / Query-Param State (nuqs) @@ -50,11 +52,13 @@ Co-locate a `search-params.ts` next to the feature. Export the parser map (and s Conventions: -- `.withDefault(...)` on every parser so reads are non-null. -- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn. +- `.withDefault(...)` on every parser so reads are non-null. A deliberately **nullable** parser (dynamic default, custom-range-only dates, nullable sort) must carry a comment saying why. +- Filter / search / toggle / pagination options: `{ history: 'replace', clearOnDefault: true }` — clean URLs, no back-stack churn. Note all three of `history: 'replace'`, `clearOnDefault: true`, and `shallow: true` are already the nuqs v2 defaults — writing the first two explicitly is documentation (and guards the groups whose options differ, e.g. `history: 'push'`), and `shallow: true` may be omitted entirely. - Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`. -- `shallow: false` **only** when a Server Component / loader must re-read the param. -- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. +- `shallow: false` **only** when a Server Component / loader must re-read the param. For loading states during the server re-render, pass React's `startTransition` via `.withOptions({ startTransition, shallow: false })`. +- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. When the parser-map key is camelCase (for clean destructuring), remap the wire key via the `urlKeys` option in the shared options object (see `files/search-params.ts` `uploadedBy: 'uploaded-by'`, `ee/audit-logs/search-params.ts` `timeRange: 'time-range'`); nuqs also exports a `UrlKeys` type helper for standalone mappings. +- `throttleMs` is deprecated in nuqs — rate-limit URL writes with `limitUrlUpdates: throttle(ms)` / `debounce(ms)` (the debounced-search hook below already does this). +- A parser **shared across surfaces with different defaults** (e.g. `parseAsTimeRange`) must `parse` unknown tokens to `null` — never to one surface's default — so each consumer's `.withDefault(...)` decides the fallback. - For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers). - A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq` — `clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`. @@ -75,11 +79,12 @@ export const thingsParsers = { /** Clean URLs, no back-stack churn for filter changes. */ export const thingsUrlKeys = { history: 'replace', - shallow: true, clearOnDefault: true, } as const ``` +(The `*UrlKeys` suffix is the repo's naming convention for a feature's shared **options** object — which may itself contain a nuqs `urlKeys` key-remapping entry; the two are different things.) + ### Client — `useQueryStates` (grouped) / `useQueryState` (single) ```typescript @@ -182,7 +187,7 @@ Sort params live alongside — not inside — the feature's grouped filter parse A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters. -**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). +**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString().slice(0, 10)`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/hooks/use-calendar.ts`. @@ -200,7 +205,11 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, { const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null ``` -Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`. +Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`. + +**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp.tsx`, `workflow-mcp-servers.tsx`, access-control, custom-blocks, forks). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update. + +**Reusable components** rendered both as a settings/list page and inside a modal (e.g. `BYOKKeyManager`) expose an optional controlled `searchTerm`/`onSearchTermChange` prop pair: the page consumer binds the URL (`useSettingsSearch()`), modal consumers omit the props and keep local state. Never bind URL state from inside a component that can mount in a non-destination context. ## Read-then-strip deep links @@ -217,8 +226,8 @@ The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/so Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery: -- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load. -- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. +- **Panel `activeTab`** — a persisted local *preference* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`); moving it would unwind that machinery and risk tab-flash on load. **Canvas mode** (`mode` on `useCanvasModeStore`) is likewise a persisted layout preference, not a destination. +- **The panel editor's `currentBlockId`** (`stores/panel/editor/store.ts` — a would-be "look at this block" deep link) — the only genuinely shareable candidate, but it is persisted and entangled with panel-open orchestration. Adding a URL param for it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas. diff --git a/apps/sim/app/(landing)/integrations/search-params.ts b/apps/sim/app/(landing)/integrations/search-params.ts index 1672807299c..d93113b80d0 100644 --- a/apps/sim/app/(landing)/integrations/search-params.ts +++ b/apps/sim/app/(landing)/integrations/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `category` filters by integration type (`''` = all). */ export const integrationsParsers = { diff --git a/apps/sim/app/(landing)/models/search-params.ts b/apps/sim/app/(landing)/models/search-params.ts index 21c7ee09ec6..210556703ab 100644 --- a/apps/sim/app/(landing)/models/search-params.ts +++ b/apps/sim/app/(landing)/models/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `provider` filters by provider id (`''` = all). */ export const modelsParsers = { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts index f03533261f6..a5775bde969 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts @@ -15,8 +15,8 @@ export const CONNECTED_LABEL = 'Connected' * pseudo-categories and are derived from the data set, so a plain string is * used; the `All` default clears from the URL. * - `search` is the integration search term. 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. + * the nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const integrationsParsers = { category: parseAsString.withDefault(ALL_CATEGORY), diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 696c3fd82c2..f34ed657dbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -137,7 +137,12 @@ export function Document({ const { workspaceId } = useParams() const router = useRouter() const [ - { page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery }, + { + page: currentPageFromURL, + chunk: chunkFromURL, + search: searchQuery, + enabled: enabledFilterParam, + }, setDocumentParams, ] = useQueryStates(documentParsers, documentUrlKeys) const userPermissions = useUserPermissionsContext() @@ -160,16 +165,31 @@ export function Document({ ) /** Raw URL value drives the input; the chunk search query always sees it trimmed. */ const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim() - const [enabledFilter, setEnabledFilter] = useState([]) const { activeSort, onSort: onSortColumn, onClear: onClearSort, } = useUrlSort(documentChunkSortParams, documentUrlKeys) - const enabledFilterParam = useMemo( - () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), - [enabledFilter] + /** Multi-select UI view of the scalar `enabled` param (`all` ↔ nothing selected). */ + const enabledFilter = useMemo( + () => (enabledFilterParam === 'all' ? [] : [enabledFilterParam]), + [enabledFilterParam] + ) + + /** + * Collapses the dropdown's multi-select values to the scalar param (one value + * filters; none or both mean `all`) and resets `page` in the same write so a + * filter change always lands on the first page. + */ + const setEnabledFilter = useCallback( + (values: string[]) => { + void setDocumentParams({ + enabled: values.length === 1 ? (values[0] as 'enabled' | 'disabled') : null, + page: null, + }) + }, + [setDocumentParams] ) const { @@ -619,8 +639,7 @@ export function Document({ const enabledDisplayLabel = useMemo(() => { if (enabledFilter.length === 0) return 'All' - if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' - return `${enabledFilter.length} selected` + return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' }, [enabledFilter]) const filterContent = useMemo( @@ -638,7 +657,6 @@ export function Document({ onMultiSelectChange={(values) => { setEnabledFilter(values) setSelectedChunks(new Set()) - void goToPage(1) }} overlayContent={ {enabledDisplayLabel} @@ -654,7 +672,6 @@ export function Document({ onClick={() => { setEnabledFilter([]) setSelectedChunks(new Set()) - void goToPage(1) }} className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]' > @@ -663,7 +680,7 @@ export function Document({ )} ), - [enabledFilter, enabledDisplayLabel, goToPage] + [enabledFilter, enabledDisplayLabel, setEnabledFilter] ) const filterTags: FilterTag[] = useMemo( @@ -671,12 +688,11 @@ export function Document({ enabledFilter.map((value) => ({ label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`, onRemove: () => { - setEnabledFilter((prev) => prev.filter((v) => v !== value)) + setEnabledFilter(enabledFilter.filter((v) => v !== value)) setSelectedChunks(new Set()) - void goToPage(1) }, })), - [enabledFilter, goToPage] + [enabledFilter, setEnabledFilter] ) const handleChunkClick = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts index 072e08d66ef..2ac0878d3a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -1,6 +1,9 @@ -import { parseAsInteger, parseAsString } from 'nuqs/server' +import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' +/** Chunk `enabled` filter buckets, matching the status filter dropdown. */ +const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const + /** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */ export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const @@ -24,11 +27,14 @@ export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS) * - `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. + * - `enabled` filters chunks by enabled status (`all` clears from the URL), + * mirroring the same filter on the knowledge base document list. */ export const documentParsers = { page: parseAsInteger.withDefault(1), chunk: parseAsString, search: parseAsString.withDefault(''), + enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'), } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts index 8a10c0796d0..e27c2e3c8b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -27,8 +27,8 @@ export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, { * * - `search` is the knowledge base name/description filter. The input is * controlled directly by the instant nuqs value; only its URL write is - * debounced via `limitUrlUpdates` (`debounce`) on the setter — never written - * on every keystroke. + * debounced via `useDebouncedSearchSetter` — never written on every + * keystroke. * - `connector` filters by connector presence; `content` filters by document * presence; `owner` filters by creator id. All are multi-select arrays. * diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts index d2171915c21..8640ba2449f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -41,12 +41,13 @@ const TOKEN_TO_TIME_RANGE: Record = Object.fromEntries( ) as Record /** - * Parser for the `timeRange` param. Serializes labels to kebab tokens and - * tolerantly maps unknown tokens back to the default ("All time"). + * Parser for the `timeRange` param. Serializes labels to kebab tokens. Unknown + * tokens parse to `null` so each consuming surface's `.withDefault(...)` decides + * the fallback (logs: "All time"; audit-logs: "Past 30 days"). */ export const parseAsTimeRange = createParser({ parse(value) { - return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE + return TOKEN_TO_TIME_RANGE[value] ?? null }, serialize(value) { return TIME_RANGE_TO_TOKEN[value] ?? 'all-time' @@ -76,6 +77,21 @@ export const parseAsLogLevel = createParser({ }, }) +/** + * Parser for free-form date/datetime strings (`startDate`/`endDate`). Rejects + * unparseable values at the URL boundary — an invalid date string reaching + * `new Date(...).toISOString()` throws, so a malformed deep link must parse to + * `null` (treated as a missing bound) instead of crashing the consumer. + */ +export const parseAsDateString = createParser({ + parse(value) { + return Number.isNaN(Date.parse(value)) ? null : value + }, + serialize(value) { + return value + }, +}) + const CORE_TRIGGER_SET = new Set(CORE_TRIGGER_TYPES) /** @@ -104,8 +120,12 @@ export const parseAsTriggers = createParser({ */ export const logFilterParsers = { timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE), - startDate: parseAsString, - endDate: parseAsString, + /** + * Deliberately nullable: only populated when timeRange is "Custom range"; + * every preset range derives its window from the label instead. + */ + startDate: parseAsDateString, + endDate: parseAsDateString, level: parseAsLogLevel.withDefault('all'), workflowIds: parseAsArrayOf(parseAsString).withDefault([]), folderIds: parseAsArrayOf(parseAsString).withDefault([]), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bdf4eb6350c..85b4a56b913 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -49,6 +49,53 @@ export const forkViewUrlKeys = { clearOnDefault: true, } as const +/** + * `server-tab` is the active tab (Details / Workflows) inside the deep-linked + * workflow MCP server detail view, so a shared `mcpServerId` link can land on + * either tab. + */ +export const serverTabParam = { + key: 'server-tab', + parser: parseAsStringLiteral(['details', 'workflows'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const serverTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-id` deep-links the Access Control settings tab to a specific + * permission group's detail sub-view (mirrors `mcpServerId` on the MCP tab). + */ +export const groupIdParam = { + key: 'group-id', + parser: parseAsString, +} as const + +/** Opening a group's detail is a destination → push to history; clear on close. */ +export const groupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** + * `custom-block-id` deep-links the Custom Blocks settings tab to a specific + * block's detail sub-view. The "create new" flow stays in local state — only + * existing entities are deep-linkable. + */ +export const customBlockIdParam = { + key: 'custom-block-id', + parser: parseAsString, +} as const + +/** Opening a block's detail is a destination → push to history; clear on close. */ +export const customBlockIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index bc1c0b2d394..ba957ffde7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -66,6 +66,13 @@ interface BYOKKeyManagerBaseProps { description?: string /** Show the provider search box (hidden when there are only a couple). */ showSearch?: boolean + /** + * Controlled search value + setter. The BYOK settings page passes the shared + * `?search=` binding (`useSettingsSearch`) so the search is deep-linkable; + * modal/embedded consumers omit both and keep local state. + */ + searchTerm?: string + onSearchTermChange?: (value: string) => void } /** One key per provider; saving replaces the stored key. */ @@ -138,7 +145,9 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { showSearch = true, } = props - const [searchTerm, setSearchTerm] = useState('') + const [localSearchTerm, setLocalSearchTerm] = useState('') + const searchTerm = props.searchTerm ?? localSearchTerm + const setSearchTerm = props.onSearchTermChange ?? setLocalSearchTerm const [editing, setEditing] = useState(null) const [apiKeyInput, setApiKeyInput] = useState('') const [nameInput, setNameInput] = useState('') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 5798db7b329..ff34151ca34 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -48,6 +48,7 @@ import { type BYOKProviderSection, } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useBYOKKeys, useDeleteBYOKKey, useUpsertBYOKKey } from '@/hooks/queries/byok-keys' import type { BYOKProviderId } from '@/tools/types' @@ -354,6 +355,7 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const workspacePermissions = useUserPermissionsContext() const canManage = canMutateWorkspaceSettingsSection('byok', workspacePermissions) + const [searchTerm, setSearchTerm] = useSettingsSearch() const { data, isLoading } = useBYOKKeys(workspaceId) const upsertKey = useUpsertBYOKKey() @@ -381,6 +383,8 @@ export function BYOK() { isSaving={upsertKey.isPending} isDeleting={deleteKey.isPending} readOnly={!canManage} + searchTerm={searchTerm} + onSearchTermChange={setSearchTerm} onSaveKey={async ({ providerId, apiKey, keyId, name }) => { await upsertKey.mutateAsync({ workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts index f21a66f2d11..d1aa319c771 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts @@ -18,7 +18,7 @@ export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number] * - `status` is the active status filter (feeds the tasks query key). * - `search` is the subject/sender/body 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 per keystroke. + * `useDebouncedSearchSetter` — never written per keystroke. */ export const inboxTaskParsers = { status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts index 8340051100e..1884a1f22b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -2,9 +2,10 @@ import { parseAsString } from 'nuqs/server' /** * Shared URL query-param definition for the settings list search boxes - * (teammates, api-keys, copilot, custom-tools, mcp, secrets, - * workflow-mcp-servers). Settings sections never co-render, so they all share - * the `search` key without collisions. + * (teammates, api-keys, byok, copilot, custom-tools, mcp, secrets, + * workflow-mcp-servers, and the ee sections: audit-logs, access-control, + * custom-blocks, data-drains, forks). Settings sections never co-render, so + * they all share the `search` key without collisions. * * Consume via `useSettingsSearch` (`settings/components/use-settings-search`), * which owns the debounced-write wiring — the input is controlled directly by diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts index 2da76cad0ee..c5749effaa4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -9,7 +9,9 @@ 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). + * api-keys, byok, copilot, custom-tools, mcp, secrets, workflow-mcp-servers, + * and the ee sections: audit-logs, access-control, custom-blocks, data-drains, + * forks). * 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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index b5c46b36462..ebbfa9883cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -32,6 +32,8 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { mcpServerIdParam, mcpServerIdUrlKeys, + serverTabParam, + serverTabUrlKeys, } 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' @@ -108,7 +110,10 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe const [editServerName, setEditServerName] = useState('') const [editServerDescription, setEditServerDescription] = useState('') const [editServerIsPublic, setEditServerIsPublic] = useState(false) - const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') + const [activeServerTab, setActiveServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) @@ -401,7 +406,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe { value: 'workflows', label: 'Workflows' }, ]} value={activeServerTab} - onChange={(value) => setActiveServerTab(value as 'workflows' | 'details')} + onChange={(value) => void setActiveServerTab(value as 'workflows' | 'details')} />
@@ -892,6 +897,11 @@ export function WorkflowMcpServers() { }) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(() => new Set()) + /** Cleared alongside the server id on close so the tab never lingers on the list URL. */ + const [, setServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const filteredServers = useMemo(() => { if (!searchTerm.trim()) return servers @@ -948,7 +958,10 @@ export function WorkflowMcpServers() { canManage={canAdmin} workspaceId={workspaceId} serverId={selectedServerId} - onBack={() => setSelectedServerId(null, { history: 'replace' })} + onBack={() => { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} /> ) } @@ -1011,7 +1024,14 @@ export function WorkflowMcpServers() { setSelectedServerId(server.id) }, + { + label: 'Details', + onSelect: () => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }, + }, ...(canAdmin ? [ { diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index ae63f33ebc4..554005fc76d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -23,8 +23,7 @@ export const skillIdUrlKeys = { /** * `search` filters the skills list by name/description. The input is controlled * directly by the instant nuqs value; only its URL write is debounced via - * `limitUrlUpdates` (`debounce`) on the setter — never written on every - * keystroke. + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const skillSearchParam = { key: 'search', diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 11c3341d9cc..813b2d9df80 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -16,11 +16,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { + groupIdParam, + groupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/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 { GroupDetail } from '@/ee/access-control/components/group-detail' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { @@ -74,8 +80,11 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon const createPermissionGroup = useCreatePermissionGroup() - const [searchTerm, setSearchTerm] = useState('') - const [selectedGroupId, setSelectedGroupId] = useState(null) + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedGroupId, setSelectedGroupId] = useQueryState(groupIdParam.key, { + ...groupIdParam.parser, + ...groupIdUrlKeys, + }) const [showCreateModal, setShowCreateModal] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') @@ -160,8 +169,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon workspaceOptions={workspaceOptions} organizationWorkspaces={organizationWorkspaces} workspacesLoading={workspacesLoading} - onBack={() => setSelectedGroupId(null)} - onDeleted={() => setSelectedGroupId(null)} + onBack={() => void setSelectedGroupId(null, { history: 'replace' })} + onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })} /> ) } @@ -198,7 +207,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon