From 30f826f0468295a604bf102346b1c5510c46399e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 16:05:07 -0700 Subject: [PATCH 1/4] improvement(url-state): migrate list search/sort/filter view-state to nuqs --- .../workspace/[workspaceId]/files/files.tsx | 93 ++++++++++++++---- .../[workspaceId]/files/search-params.ts | 45 ++++++++- .../[workspaceId]/knowledge/knowledge.tsx | 97 ++++++++++++++++--- .../[workspaceId]/knowledge/page.tsx | 12 ++- .../[workspaceId]/knowledge/search-params.ts | 53 ++++++++++ .../app/workspace/[workspaceId]/logs/logs.tsx | 41 +++++--- .../[workspaceId]/logs/search-params.ts | 26 +++++ .../settings/components/api-keys/api-keys.tsx | 13 ++- .../settings/components/copilot/copilot.tsx | 13 ++- .../components/custom-tools/custom-tools.tsx | 13 ++- .../settings/components/mcp/mcp.tsx | 14 ++- .../settings/components/search-params.ts | 22 +++++ .../secrets-manager/secrets-manager.tsx | 13 ++- .../components/teammates/search-params.ts | 18 ---- .../components/teammates/teammates.tsx | 14 +-- .../workflow-mcp-servers.tsx | 27 ++++-- .../[workspaceId]/skills/search-params.ts | 18 ++++ .../workspace/[workspaceId]/skills/skills.tsx | 35 ++++++- 18 files changed, 470 insertions(+), 97 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 1d55ca37628..8f47b6a1664 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -22,7 +22,7 @@ import { Download, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' -import { useQueryStates } from 'nuqs' +import { debounce, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' @@ -75,7 +75,14 @@ import { import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' -import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params' +import { + FILE_SORT_COLUMNS, + type FileSortColumn, + filesFilterParsers, + filesFilterUrlKeys, + filesParsers, + filesUrlKeys, +} from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' @@ -104,6 +111,9 @@ type FileResourceItem = const logger = createLogger('Files') +/** Debounce window for `search` URL writes and filtering; the input itself stays instant. */ +const SEARCH_DEBOUNCE_MS = 200 as const + const SUPPORTED_EXTENSIONS = [ ...SUPPORTED_DOCUMENT_EXTENSIONS, ...SUPPORTED_CODE_EXTENSIONS, @@ -244,15 +254,61 @@ export function Files() { }) const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) - const [inputValue, setInputValue] = useState('') - const debouncedSearchTerm = useDebounce(inputValue, 200) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) - const [typeFilter, setTypeFilter] = useState([]) - const [sizeFilter, setSizeFilter] = useState([]) - const [uploadedByFilter, setUploadedByFilter] = useState([]) + const [ + { + search: urlSearchTerm, + sort: sortColumn, + dir: sortDirection, + type: typeFilter, + size: sizeFilter, + uploadedBy: uploadedByFilter, + }, + setFileFilters, + ] = useQueryStates(filesFilterParsers, filesFilterUrlKeys) + + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The in-memory filter below still reads a debounced value + * so it doesn't recompute on every keystroke. + */ + const setSearchTerm = useCallback( + (value: string) => { + const next = value.length > 0 ? value : null + setFileFilters( + { search: next }, + next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } + ) + }, + [setFileFilters] + ) + const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) + + /** + * `sort`/`dir` are nullable in the URL because "no active sort" is distinct + * from an explicit updated/desc selection: with no sort, files fall back to + * updated/desc but folders to name/asc, while an explicit sort orders both + * sections by the chosen column. + */ + const activeSort = useMemo( + () => + sortColumn !== null && sortDirection !== null + ? { column: sortColumn, direction: sortDirection } + : null, + [sortColumn, sortDirection] + ) + + const setTypeFilter = useCallback( + (next: string[]) => setFileFilters({ type: next }), + [setFileFilters] + ) + const setSizeFilter = useCallback( + (next: string[]) => setFileFilters({ size: next }), + [setFileFilters] + ) + const setUploadedByFilter = useCallback( + (next: string[]) => setFileFilters({ uploadedBy: next }), + [setFileFilters] + ) const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) @@ -1523,9 +1579,9 @@ export function Files() { }, [canEdit, uploading]) const searchConfig: SearchConfig = { - value: inputValue, - onChange: setInputValue, - onClearAll: () => setInputValue(''), + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), placeholder: 'Search files...', } @@ -1689,10 +1745,13 @@ export function Files() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + onSort: (column, direction) => { + if (!(FILE_SORT_COLUMNS as readonly string[]).includes(column)) return + setFileFilters({ sort: column as FileSortColumn, dir: direction }) + }, + onClear: () => setFileFilters({ sort: null, dir: null }), }), - [activeSort] + [activeSort, setFileFilters] ) const hasActiveFilters = diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts index d1c35f80e58..0a093314087 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -1,4 +1,11 @@ -import { createParser, parseAsString } from 'nuqs/server' +import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Sortable list columns, matching the `Resource.Options` sort menu. */ +export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const + +export type FileSortColumn = (typeof FILE_SORT_COLUMNS)[number] + +const SORT_DIRECTIONS = ['asc', 'desc'] as const /** * Parser for the `new` flag. Preserves the prior `?new=1` wire format on @@ -47,3 +54,39 @@ export const filesUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * Co-located, typed URL query-param definitions for the Files list's + * filter/search/sort view-state, grouped separately from the navigation params + * above because filter writes must never land in the browser history. + * + * - `search` is the file/folder name filter. The input is controlled directly + * by the nuqs value; only its URL write is debounced via `limitUrlUpdates` + * (`debounce`) on the setter — never written on every keystroke. + * - `sort` / `dir` follow the shared sort convention (two scalar params). They + * are intentionally nullable (no `.withDefault`) because "no active sort" is + * behaviorally distinct from explicitly sorting by the fallback column: with + * no sort, files order by updated/desc but folders by name/asc, while an + * explicit updated/desc sorts both sections by updatedAt. Collapsing the + * explicit selection into a clean URL would make that folder ordering + * unreachable. Clearing the sort writes `null`, which strips both params. + * - `type` filters by file kind (document/image/audio/video); `size` filters by + * size bucket (small/medium/large); `uploadedBy` filters by uploader user id + * (URL key `uploaded-by`). All three are multi-select arrays. + */ +export const filesFilterParsers = { + search: parseAsString.withDefault(''), + sort: parseAsStringLiteral(FILE_SORT_COLUMNS), + dir: parseAsStringLiteral(SORT_DIRECTIONS), + type: parseAsArrayOf(parseAsString).withDefault([]), + size: parseAsArrayOf(parseAsString).withDefault([]), + uploadedBy: parseAsArrayOf(parseAsString).withDefault([]), +} as const + +/** Filter/search/sort view-state: clean URLs, no back-stack churn. */ +export const filesFilterUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, + urlKeys: { uploadedBy: 'uploaded-by' }, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index be7b5ccd801..c0ab237b3b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -6,6 +6,7 @@ import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn' import { Database } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' +import { debounce, useQueryStates } from 'nuqs' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import type { FilterTag, @@ -30,6 +31,14 @@ import { KnowledgeBaseContextMenu, KnowledgeListContextMenu, } from '@/app/workspace/[workspaceId]/knowledge/components' +import { + DEFAULT_KNOWLEDGE_SORT_COLUMN, + DEFAULT_KNOWLEDGE_SORT_DIRECTION, + KNOWLEDGE_SORT_COLUMNS, + type KnowledgeSortColumn, + knowledgeParsers, + knowledgeUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/search-params' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -42,6 +51,9 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' const logger = createLogger('Knowledge') +/** Debounce window for `search` URL writes; the input itself stays instant. */ +const SEARCH_DEBOUNCE_MS = 300 as const + interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { docCount?: number } @@ -143,16 +155,60 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) - const [connectorFilter, setConnectorFilter] = useState([]) - const [contentFilter, setContentFilter] = useState([]) - const [ownerFilter, setOwnerFilter] = useState([]) + const [ + { + search: urlSearchQuery, + sort: sortColumn, + dir: sortDirection, + connector: connectorFilter, + content: contentFilter, + owner: ownerFilter, + }, + setKnowledgeFilters, + ] = useQueryStates(knowledgeParsers, knowledgeUrlKeys) + + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The in-memory filter below still reads a debounced + * value so it doesn't recompute on every keystroke. + */ + const setSearchQuery = useCallback( + (value: string) => { + const next = value.length > 0 ? value : null + setKnowledgeFilters( + { search: next }, + next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } + ) + }, + [setKnowledgeFilters] + ) + const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) + + /** + * The resolved sort is exposed to the sort menu only when it differs from the + * default, mirroring the prior `null`-means-default semantics. + */ + const activeSort = useMemo( + () => + sortColumn === DEFAULT_KNOWLEDGE_SORT_COLUMN && + sortDirection === DEFAULT_KNOWLEDGE_SORT_DIRECTION + ? null + : { column: sortColumn, direction: sortDirection }, + [sortColumn, sortDirection] + ) - const [searchInputValue, setSearchInputValue] = useState('') - const debouncedSearchQuery = useDebounce(searchInputValue, 300) + const setConnectorFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ connector: next }), + [setKnowledgeFilters] + ) + const setContentFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ content: next }), + [setKnowledgeFilters] + ) + const setOwnerFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ owner: next }), + [setKnowledgeFilters] + ) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) @@ -402,12 +458,12 @@ export function Knowledge() { const searchConfig: SearchConfig = useMemo( () => ({ - value: searchInputValue, - onChange: setSearchInputValue, - onClearAll: () => setSearchInputValue(''), + value: urlSearchQuery, + onChange: setSearchQuery, + onClearAll: () => setSearchQuery(''), placeholder: 'Search knowledge bases...', }), - [searchInputValue] + [urlSearchQuery, setSearchQuery] ) const sortConfig: SortConfig = useMemo( @@ -422,10 +478,19 @@ export function Knowledge() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + onSort: (column, direction) => { + const sort = (KNOWLEDGE_SORT_COLUMNS as readonly string[]).includes(column) + ? (column as KnowledgeSortColumn) + : DEFAULT_KNOWLEDGE_SORT_COLUMN + setKnowledgeFilters({ sort, dir: direction }) + }, + onClear: () => + setKnowledgeFilters({ + sort: DEFAULT_KNOWLEDGE_SORT_COLUMN, + dir: DEFAULT_KNOWLEDGE_SORT_DIRECTION, + }), }), - [activeSort] + [activeSort, setKnowledgeFilters] ) const memberOptions: ChipDropdownOption[] = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 6243dc42035..402d437b4f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { Knowledge } from './knowledge' @@ -8,6 +10,12 @@ export const metadata: Metadata = { title: 'Knowledge Base', } +/** + * Knowledge Base page entry. `Knowledge` reads URL query params via nuqs (which + * uses `useSearchParams` internally), so it must sit under a Suspense boundary. + * The fallback renders the real chrome so a suspend never shows a blank frame; + * the route-level `loading.tsx` covers the navigation/chunk-load transition. + */ export default async function KnowledgePage({ params, }: { @@ -20,7 +28,9 @@ export default async function KnowledgePage({ return ( - + }> + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts new file mode 100644 index 00000000000..cd35244171d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -0,0 +1,53 @@ +import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */ +export const KNOWLEDGE_SORT_COLUMNS = [ + 'name', + 'documents', + 'tokens', + 'connectors', + 'created', + 'owner', + 'updated', +] as const + +export type KnowledgeSortColumn = (typeof KNOWLEDGE_SORT_COLUMNS)[number] + +const SORT_DIRECTIONS = ['asc', 'desc'] as const + +/** Default sort: most-recently-updated first. */ +export const DEFAULT_KNOWLEDGE_SORT_COLUMN: KnowledgeSortColumn = 'updated' +export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc' + +/** + * Co-located, typed URL query-param definitions for the Knowledge Base list. + * + * - `search` is the knowledge base name/description filter. The input is + * controlled directly by the instant nuqs value; only its URL write is + * debounced via `limitUrlUpdates` (`debounce`) on the setter — never written + * on every keystroke. + * - `sort` / `dir` follow the shared sort convention (two scalar params). "No + * active sort" is derived in the component as `sort === DEFAULT && dir === + * DEFAULT`. + * - `connector` filters by connector presence; `content` filters by document + * presence; `owner` filters by creator id. All are multi-select arrays. + * + * Selecting a knowledge base navigates to the `knowledge/[id]` route (via + * `router`), so the active knowledge base is route state, not query state, and + * is intentionally not represented here. + */ +export const knowledgeParsers = { + search: parseAsString.withDefault(''), + sort: parseAsStringLiteral(KNOWLEDGE_SORT_COLUMNS).withDefault(DEFAULT_KNOWLEDGE_SORT_COLUMN), + dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KNOWLEDGE_SORT_DIRECTION), + connector: parseAsArrayOf(parseAsString).withDefault([]), + content: parseAsArrayOf(parseAsString).withDefault([]), + owner: parseAsArrayOf(parseAsString).withDefault([]), +} as const + +/** Filter/search/sort view-state: clean URLs, no back-stack churn. */ +export const knowledgeUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index cfdeeaad72d..15028e13f85 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -26,7 +26,7 @@ import { Download, Workflow } from '@sim/emcn/icons' import { formatDuration } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' -import { useQueryState } from 'nuqs' +import { useQueryState, useQueryStates } from 'nuqs' import type { WorkflowLogDetail, WorkflowLogRow, @@ -58,9 +58,14 @@ import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { + DEFAULT_LOG_SORT_COLUMN, + DEFAULT_LOG_SORT_DIRECTION, executionIdParam, + LOG_SORT_COLUMNS, logDetailsTabParam, logDetailsTabUrlKeys, + logFilterUrlKeys, + logSortParsers, } from '@/app/workspace/[workspaceId]/logs/search-params' import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -97,7 +102,6 @@ import { } from './utils' const LOGS_PER_PAGE = 50 as const -const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const const REFRESH_SPINNER_DURATION_MS = 1000 as const const LIVE_REFRESH_INTERVAL_MS = 10_000 as const const ACTIVE_RUN_DETAIL_REFRESH_MS = 3_000 as const @@ -270,10 +274,13 @@ export default function Logs() { const activeLogRefetchRef = useRef<() => void>(() => {}) const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) + + /** + * URL-backed sort (`sort` + `dir`). The defaults match the server's default + * ordering, so a clean URL means "no active sort" and clearing the sort + * writes the defaults back (which `clearOnDefault` strips from the URL). + */ + const [sortParams, setSortParams] = useQueryStates(logSortParsers, logFilterUrlKeys) const userPermissions = useUserPermissionsContext() const [contextMenuOpen, setContextMenuOpen] = useState(false) @@ -301,11 +308,8 @@ export default function Logs() { refetchInterval, }) - const sortBy: LogSortBy = - activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy) - ? (activeSort.column as LogSortBy) - : 'date' - const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc' + const sortBy: LogSortBy = sortParams.sort + const sortOrder: LogSortOrder = sortParams.dir const logFilters = useMemo( () => ({ @@ -1016,11 +1020,18 @@ export default function Logs() { { id: 'cost', label: 'Cost' }, { id: 'status', label: 'Status' }, ], - active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + active: + sortParams.sort === DEFAULT_LOG_SORT_COLUMN && sortParams.dir === DEFAULT_LOG_SORT_DIRECTION + ? null + : { column: sortParams.sort, direction: sortParams.dir }, + onSort: (column, direction) => { + if (!(LOG_SORT_COLUMNS as readonly string[]).includes(column)) return + setSortParams({ sort: column as LogSortBy, dir: direction }) + }, + onClear: () => + setSortParams({ sort: DEFAULT_LOG_SORT_COLUMN, dir: DEFAULT_LOG_SORT_DIRECTION }), }), - [activeSort] + [sortParams, setSortParams] ) const searchConfig = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts index 643e9f992cc..b68e0d667d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -1,4 +1,5 @@ import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' import { CORE_TRIGGER_TYPES, type LogLevel, @@ -116,6 +117,31 @@ export const logFilterUrlKeys = { clearOnDefault: true, } as const +/** Columns the logs list can sort by; must stay in sync with {@link LogSortBy}. */ +export const LOG_SORT_COLUMNS = [ + 'date', + 'duration', + 'cost', + 'status', +] as const satisfies readonly LogSortBy[] + +const LOG_SORT_DIRECTIONS = ['asc', 'desc'] as const satisfies readonly LogSortOrder[] + +/** Default ordering the server applies when no sort is active (newest first). */ +export const DEFAULT_LOG_SORT_COLUMN: LogSortBy = 'date' +export const DEFAULT_LOG_SORT_DIRECTION: LogSortOrder = 'desc' + +/** + * Sort params for the logs resource table (`sort` + `dir`). The defaults match + * the server's default ordering exactly, so with `clearOnDefault` a clean URL + * means "no active sort" and clearing the sort strips both params. Shares + * {@link logFilterUrlKeys} so sort changes replace history like filter changes. + */ +export const logSortParsers = { + sort: parseAsStringLiteral(LOG_SORT_COLUMNS).withDefault(DEFAULT_LOG_SORT_COLUMN), + dir: parseAsStringLiteral(LOG_SORT_DIRECTIONS).withDefault(DEFAULT_LOG_SORT_DIRECTION), +} as const + /** * Read-only deep link to a specific execution. Resolves to a log row and opens * the details sidebar on load. Intentionally NOT stripped — the link stays diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index d494850bafb..ed8be3af308 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -6,10 +6,15 @@ import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Info, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { debounce, useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -105,7 +110,11 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const defaultKeyType = isPersonalScope ? 'personal' @@ -184,7 +193,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { void setSearchTerm(value), placeholder: 'Search API keys...', }} actions={actions} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index 82a6be65d86..40c16c8ff99 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -15,6 +15,11 @@ import { import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' +import { debounce, useQueryState } from 'nuqs' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -48,7 +53,11 @@ export function Copilot() { const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const [createError, setCreateError] = useState(null) const filteredKeys = useMemo(() => { @@ -123,7 +132,7 @@ export function Copilot() { void setSearchTerm(value), placeholder: 'Search API keys...', }} actions={actions} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 7254477ac83..4dd6dc08466 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -6,9 +6,14 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { debounce, useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -26,7 +31,11 @@ export function CustomTools() { const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId) const deleteToolMutation = useDeleteCustomTool() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const [deletingTools, setDeletingTools] = useState>(() => new Set()) const [editingTool, setEditingTool] = useState(null) const [showAddForm, setShowAddForm] = useState(false) @@ -108,7 +117,7 @@ export function CustomTools() { void setSearchTerm(value), placeholder: 'Search tools...', disabled: isLoading, }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 6894bb87684..cdd10441dda 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { useParams } from 'next/navigation' -import { useQueryState } from 'nuqs' +import { debounce, useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' @@ -24,6 +24,10 @@ import { mcpServerIdUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -187,7 +191,11 @@ export function MCP() { const [showAddModal, setShowAddModal] = useState(false) const [editingServerId, setEditingServerId] = useState(null) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const [deletingServers, setDeletingServers] = useState>(() => new Set()) const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({ workspaceId, @@ -617,7 +625,7 @@ export function MCP() { void setSearchTerm(value), placeholder: 'Search MCPs...', }} actions={ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts new file mode 100644 index 00000000000..2c9a281770a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -0,0 +1,22 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Shared URL query-param definition for the settings list search boxes + * (teammates, api-keys, copilot, custom-tools, mcp, secrets, + * workflow-mcp-servers). Settings sections never co-render, so they all share + * the `search` key without collisions. + * + * The input is controlled directly by the nuqs value; only its URL write is + * debounced via `limitUrlUpdates` at the hook options. + */ +export const settingsSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search view-state: clean URLs, no back-stack churn. */ +export const settingsSearchUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 03481c6c7c6..fe5b40c7a0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' +import { debounce, useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { clearPendingCredentialCreateRequest, @@ -16,6 +17,10 @@ import { import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' @@ -362,7 +367,11 @@ export function SecretsManager() { const [newWorkspaceRows, setNewWorkspaceRows] = useState([ createEmptyEnvVar(), ]) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) const [workspaceVars, setWorkspaceVars] = useState>({}) const [renamingKey, setRenamingKey] = useState(null) @@ -951,7 +960,7 @@ export function SecretsManager() { scrollContainerRef={scrollContainerRef} search={{ value: searchTerm, - onChange: setSearchTerm, + onChange: (value) => void setSearchTerm(value), placeholder: 'Search secrets...', }} actions={[ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts deleted file mode 100644 index 3366760fbc4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseAsString } from 'nuqs/server' - -/** - * Co-located, typed URL query-param definition for the Teammates view. - * - * `search` is the name/email filter. The input is controlled directly by the - * nuqs value; only its URL write is debounced via `limitUrlUpdates`. - */ -export const teammatesSearchParam = { - key: 'search', - parser: parseAsString.withDefault(''), -} as const - -/** Search view-state: clean URLs, no back-stack churn. */ -export const teammatesUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index db0b4b91738..35f48d0ab7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -21,11 +21,11 @@ import { MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { - teammatesSearchParam, - teammatesUrlKeys, -} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params' + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { useCancelWorkspaceInvitation, @@ -74,9 +74,9 @@ export function Teammates() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, { - ...teammatesSearchParam.parser, - ...teammatesUrlKeys, + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, limitUrlUpdates: debounce(300), }) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 984f5cac0bf..fe142f1b426 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -24,12 +24,21 @@ import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' -import { useParams, useSearchParams } from 'next/navigation' +import { useParams } from 'next/navigation' +import { debounce, useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + mcpServerIdParam, + mcpServerIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -871,7 +880,6 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe export function WorkflowMcpServers() { const params = useParams() const workspaceId = params.workspaceId as string - const searchParams = useSearchParams() const workspacePermissions = useUserPermissionsContext() const canAdmin = canMutateWorkspaceSettingsSection('workflow-mcp-servers', workspacePermissions) @@ -879,11 +887,16 @@ export function WorkflowMcpServers() { const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId) const deleteServerMutation = useDeleteWorkflowMcpServer() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + limitUrlUpdates: debounce(300), + }) const [showAddModal, setShowAddModal] = useState(false) - const [selectedServerId, setSelectedServerId] = useState(() => - searchParams.get('mcpServerId') - ) + const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, { + ...mcpServerIdParam.parser, + ...mcpServerIdUrlKeys, + }) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(() => new Set()) @@ -953,7 +966,7 @@ export function WorkflowMcpServers() { void setSearchTerm(value), placeholder: 'Search servers...', }} actions={actions} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index 4c7cf55fc45..ae63f33ebc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -19,3 +19,21 @@ export const skillIdUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * `search` filters the skills list by name/description. The input is controlled + * directly by the instant nuqs value; only its URL write is debounced via + * `limitUrlUpdates` (`debounce`) on the setter — never written on every + * keystroke. + */ +export const skillSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search is filter view-state: clean URLs, no back-stack churn. */ +export const skillSearchUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 182cc48af45..d03949d9756 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -1,23 +1,30 @@ 'use client' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' -import { useQueryState } from 'nuqs' +import { debounce, useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal' -import { skillIdParam, skillIdUrlKeys } from '@/app/workspace/[workspaceId]/skills/search-params' +import { + skillIdParam, + skillIdUrlKeys, + skillSearchParam, + skillSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/skills/search-params' import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' const logger = createLogger('SkillsSettings') const SKILLS_LABEL = 'Skills' +const SEARCH_DEBOUNCE_MS = 300 as const + interface SkillItemProps { name: string description: string @@ -67,7 +74,10 @@ export function Skills() { const { data: skills = [], isLoading, error } = useSkills(workspaceId) const deleteSkillMutation = useDeleteSkill() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, { + ...skillSearchParam.parser, + ...skillSearchUrlKeys, + }) const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, { ...skillIdParam.parser, ...skillIdUrlKeys, @@ -76,6 +86,23 @@ export function Skills() { const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. Filtering below is cheap in-memory over a small list, + * so it reads the instant value too. Clearing writes immediately so the + * param drops out without lingering. + */ + const setSearchTerm = useCallback( + (value: string) => { + const next = value.length > 0 ? value : null + setSearchTermParam( + next, + next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } + ) + }, + [setSearchTermParam] + ) + /** Derive the skill being edited from the loaded list — never store the object in the URL. */ const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null From 492e0bde4db952f9aaec19dda746893ed9e4fe21 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 16:54:52 -0700 Subject: [PATCH 2/4] fix(url-state): clear mcpServerId selection when the selected workflow MCP server is deleted --- .../components/workflow-mcp-servers/workflow-mcp-servers.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 fe142f1b426..57ded578533 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 @@ -924,6 +924,9 @@ export function WorkflowMcpServers() { workspaceId, serverId: serverToDelete.id, }) + if (selectedServerId === serverToDelete.id) { + void setSelectedServerId(null, { history: 'replace' }) + } } catch (err) { logger.error('Failed to delete server:', err) } finally { From 48df8f60f2976e660f0c09e37014e046b7351faa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:16:12 -0700 Subject: [PATCH 3/4] fix(url-state): resolve-gated MCP server detail view + replace-on-close for detail back navigation --- .../settings/components/mcp/mcp.tsx | 3 ++- .../workflow-mcp-servers.tsx | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index cdd10441dda..b6e5d635e16 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -269,8 +269,9 @@ export function MCP() { refetchStoredTools() } + /** Closing replaces the URL — Back should leave the section, not reopen the detail view. */ const handleBackToList = () => { - setSelectedServerId(null) + setSelectedServerId(null, { history: 'replace' }) setExpandedTools(new Set()) } 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 57ded578533..6e1aeef0aaf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -924,9 +924,6 @@ export function WorkflowMcpServers() { workspaceId, serverId: serverToDelete.id, }) - if (selectedServerId === serverToDelete.id) { - void setSelectedServerId(null, { history: 'replace' }) - } } catch (err) { logger.error('Failed to delete server:', err) } finally { @@ -941,13 +938,24 @@ export function WorkflowMcpServers() { const hasServers = servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers - if (selectedServerId) { + /** + * Render the detail view only while the id can still resolve — while the + * list loads (a fresh deep link) or when the server exists in it. A stale id + * (deleted server restored from an old history entry or a dead link) falls + * back to the list instead of a failed detail view; the lingering param is + * harmless and gets overwritten by the next selection. Closing replaces the + * URL so Back leaves the section rather than reopening the detail view. + */ + const selectedServerResolves = + selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId)) + + if (selectedServerId && selectedServerResolves) { return ( setSelectedServerId(null)} + onBack={() => setSelectedServerId(null, { history: 'replace' })} /> ) } From 422cfd4e86b4b1d0a9b4a2a1c4b8c9e51323a7f2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 17:36:12 -0700 Subject: [PATCH 4/4] fix(url-state): explicit Suspense boundary for account settings sections --- apps/sim/app/account/settings/[section]/page.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 4d883e9ee3a..71f10cbea0e 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer' @@ -50,5 +51,15 @@ export default async function AccountSettingsSectionPage({ if (!isSuperUser) notFound() } - return + /** + * Sections read URL query params via nuqs (which uses `useSearchParams` + * internally), so the renderer must sit under a Suspense boundary. The + * `null` fallback matches the existing visual behavior — the sections are + * `next/dynamic` components that render nothing while their chunk loads. + */ + return ( + + + + ) }