diff --git a/.claude/commands/cleanup.md b/.claude/commands/cleanup.md index e0e0f44669..34bce559f4 100644 --- a/.claude/commands/cleanup.md +++ b/.claude/commands/cleanup.md @@ -1,5 +1,5 @@ --- -description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, and emcn design review +description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, emcn design review, and url-state argument-hint: [scope] [fix=true|false] --- @@ -21,5 +21,6 @@ Run each of these skills in order on the specified scope, passing through the sc 4. `/you-might-not-need-state $ARGUMENTS` 5. `/react-query-best-practices $ARGUMENTS` 6. `/emcn-design-review $ARGUMENTS` +7. `/you-might-not-need-url-state $ARGUMENTS` -After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes. +After all skills have run, output a summary of what was found and fixed (or proposed) across all seven passes. diff --git a/.claude/commands/you-might-not-need-url-state.md b/.claude/commands/you-might-not-need-url-state.md new file mode 100644 index 0000000000..77ba1ebecc --- /dev/null +++ b/.claude/commands/you-might-not-need-url-state.md @@ -0,0 +1,45 @@ +--- +description: Analyze and fix URL/query-param state anti-patterns — manual useSearchParams reads, hand-built query mutations, view-state trapped in useState, and objects in the URL +argument-hint: [scope] [fix=true|false] +--- + +# You Might Not Need URL State + +Arguments: +- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase" +- fix: whether to apply fixes (default: true). Set to false to only propose changes. + +User arguments: $ARGUMENTS + +## Context + +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`. + +`.claude/rules/sim-url-state.md` is the source of truth — read it first. + +## References + +Read these before analyzing: +1. `.claude/rules/sim-url-state.md` — the decision framework, conventions, debounced-input pattern, sort convention, selected-entity deep-link pattern, and the workflow-editor carve-out +2. https://nuqs.dev/docs/parsers — parsers (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`) +3. https://nuqs.dev/docs/options — `withDefault`, `history`, `shallow`, `clearOnDefault` +4. https://nuqs.dev/docs/server-side — `createSearchParamsCache` for server reads + +## Anti-patterns to detect + +1. **Manual param reads for state**: `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` used to *read* view-state. Replace with `useQueryState`/`useQueryStates` bound to a `search-params.ts`. (Read-once auth/invite/redirect tokens — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `code` — are NOT view-state; leave them on `useSearchParams`.) +2. **Hand-built query mutation**: constructing a query string + `router.replace`/`router.push` to change a param on the current path. Use a nuqs setter. (A `router.push` that changes the route *path* is fine; an outbound `new URLSearchParams` building an `href`/`window.open`/download/API URL is fine.) +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. +7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. +8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. +9. **`import { z }` for param validation in client code**: use nuqs parsers instead. + +## 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. diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index f1d7270f0c..1eb89ca5d1 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -7,6 +7,8 @@ paths: All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations. +For *client* view-state that belongs in a shareable link (tabs, filters, search, pagination, selected entity id), use URL query params via nuqs — see `.claude/rules/sim-url-state.md`. React Query owns remote data; nuqs owns shareable client view-state. + ## Query Key Factory Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation: diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md new file mode 100644 index 0000000000..b3be536a0a --- /dev/null +++ b/.claude/rules/sim-url-state.md @@ -0,0 +1,215 @@ +--- +paths: + - "apps/sim/app/**/*.tsx" + - "apps/sim/app/**/*.ts" + - "apps/sim/app/**/search-params.ts" +--- + +# URL / Query-Param State (nuqs) + +URL query state is managed with [`nuqs`](https://nuqs.dev). The `NuqsAdapter` is wired once in `apps/sim/app/layout.tsx` — do not add another. This rule is the source of truth for *what* belongs in the URL and *how* to wire it. + +## Decision framework — where does this state live? + +Pick exactly one home for each piece of state: + +- **React Query** → server/remote data. Unchanged; see `.claude/rules/sim-queries.md`. +- **URL params (nuqs)** → client view-state worth putting in a link: active tab/panel, selected entity id, filters, search query, pagination, view mode (list/grid), an open "view" drawer/modal that represents a destination. +- **Zustand** → cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, or socket-synced (canvas pan/zoom, cursor, drag state, resize widths, unsaved buffers, live collaborative selection). +- **`useState`** → purely local, single-component UI. + +Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL. + +### When to use what (decision table) + +| Home | Trigger | Example | +| --- | --- | --- | +| **URL (nuqs)** | Client view-state worth a link: tab, filter, search, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination | `?tab=licenses`, `?category=Communication`, `?page=3`, `?skillId=abc` | +| **React Query** | Server/remote data fetched from an endpoint | `useMcpServers(workspaceId)`, `useSkills(workspaceId)` | +| **Zustand** | Cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, socket-synced | canvas pan/zoom, live cursor, drag state, resize widths, unsaved buffers | +| **`useState`** | Purely local single-component UI; also the snappy mirror of a debounced URL search | a hover flag, a transient dialog target, the live text of a debounced search box | + +## Anti-patterns (forbidden) + +- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state. +- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. +- `window.history.replaceState`/`pushState` to mutate a param. +- Duplicating URL state into a store and syncing it with effects / `popstate` listeners. +- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs). +- `import { z } from 'zod'` in client code for param validation — use nuqs parsers (`parseAsString`, `parseAsInteger`, `parseAsBoolean`, `parseAsStringLiteral`, `parseAsArrayOf`) or a custom `createParser`. + +These reads/mutations are **not** anti-patterns and stay as-is: + +- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination. +- **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`. +- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. + +## Per-feature `search-params.ts` — single source of truth + +Co-locate a `search-params.ts` next to the feature. Export the parser map (and shared options). Both the client (`useQueryStates`/`useQueryState`) and any server component (`createSearchParamsCache` from `nuqs/server`) import from this one file. Import parsers from `nuqs/server` so the module is safe to import in both client and server contexts. + +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. +- 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. +- 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])`. + +### Example — grouped filters (single source of truth) + +```typescript +// apps/sim/app/workspace/[workspaceId]/things/search-params.ts +import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +const VIEW_MODES = ['list', 'grid'] as const + +export const thingsParsers = { + search: parseAsString.withDefault(''), + tags: parseAsArrayOf(parseAsString).withDefault([]), + view: parseAsStringLiteral(VIEW_MODES).withDefault('list'), +} as const + +/** Clean URLs, no back-stack churn for filter changes. */ +export const thingsUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const +``` + +### Client — `useQueryStates` (grouped) / `useQueryState` (single) + +```typescript +'use client' + +import { useQueryStates } from 'nuqs' +import { thingsParsers, thingsUrlKeys } from '@/app/workspace/[workspaceId]/things/search-params' + +export function useThingFilters() { + const [filters, setFilters] = useQueryStates(thingsParsers, thingsUrlKeys) + // filters.search / filters.tags / filters.view are non-null (defaults applied) + // setFilters({ view: 'grid' }) — pass null to clear a single key back to default + return { filters, setFilters } +} +``` + +For a single param, use `useQueryState(key, parser)`: + +```typescript +const [serverId, setServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser) +``` + +### Server — `createSearchParamsCache` + +When a Server Component or loader must read a param, build a cache from the **same** parser map: + +```typescript +// in a server component / page.tsx +import { createSearchParamsCache } from 'nuqs/server' +import { thingsParsers } from '@/app/workspace/[workspaceId]/things/search-params' + +const thingsCache = createSearchParamsCache(thingsParsers) + +export default async function Page({ searchParams }: { searchParams: Promise> }) { + const { search, view } = await thingsCache.parse(await searchParams) + // ... +} +``` + +If a client param must be re-read server-side after a change, set `shallow: false` on the write. + +## Suspense boundary + +`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`. + +## 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. + +- **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 { debounce } from 'nuqs' + + 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] + ) + ``` + +- **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). + +## Sort convention (`sort` + `dir`) + +Sortable lists use **two scalar params**, never a serialized `{column,direction}` object: + +```typescript +const SORT_COLUMNS = ['name', 'created', 'updated'] as const +const SORT_DIRECTIONS = ['asc', 'desc'] as const + +export const thingsParsers = { + sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'), + dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'), +} as const +``` + +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. + +## Dates in the URL (date-only params) + +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`). + +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`. + +## Selected-entity deep-link (store the id, derive the object) + +To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL: + +```typescript +const [skillId, setSkillId] = useQueryState(skillIdParam.key, { + ...skillIdParam.parser, + history: 'push', // opening an entity is a destination; "back" closes it + clearOnDefault: true, +}) +// Derive — do not duplicate into useState or sync with an effect: +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`. + +## Read-then-strip deep links + +For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`. + +## Workflow editor carve-out — what must NOT go in the URL + +The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/socket-synced via `socket-provider.tsx`. Its view-state is intentionally store-backed (Zustand), not URL-backed. Do **not** move the following into the URL: + +- **Live cursor** and **broadcast live selection** (presence; emitted over the socket, throttled). +- **Pan / zoom / viewport** (ReactFlow-owned, continuous, not persisted). +- **Drag state** and **resize widths/heights** (panel/terminal/sidebar — high-frequency, persisted as local preferences). +- **Ephemeral diff staging** (`hasActiveDiff`, `baselineWorkflow`, `diffAnalysis`). + +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. + +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. + +## Docs + +- Adapters (App Router `NuqsAdapter`): https://nuqs.dev/docs/adapters +- Parsers & options (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`, `withDefault`, `history`, `shallow`, `clearOnDefault`): https://nuqs.dev/docs/parsers and https://nuqs.dev/docs/options +- Server-side reads (`createSearchParamsCache`): https://nuqs.dev/docs/server-side diff --git a/CLAUDE.md b/CLAUDE.md index 9ce16b909d..253af04793 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -367,6 +367,12 @@ export function useUpdateEntity() { } ``` +## URL / Query-Param State + +Shareable *client* view-state (active tab/panel, filters, search query, pagination, selected entity id, view mode, a deep-linked drawer/modal) lives in the URL via [`nuqs`](https://nuqs.dev) — not in a store synced with effects, and never read via `useSearchParams().get(...)` / `new URLSearchParams(window.location.search)`. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand (canvas pan/zoom, cursor, drag, resize widths, live collaborative selection). + +Co-locate a `search-params.ts` per feature exporting the parser map (single source of truth, shared by client `useQueryStates`/`useQueryState` and server `createSearchParamsCache`). Never `import { z }` in client code for params — use nuqs parsers. Full decision framework, conventions, the debounced-input pattern, and the workflow-editor carve-out are in `.claude/rules/sim-url-state.md`. + ## Styling Use Tailwind only, no inline styles. Use `cn()` from `@/lib/core/utils/cn` for conditional classes. diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 4ab0bddef7..11b83f0b4a 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { PublicEnvScript } from 'next-runtime-env' +import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' @@ -242,17 +243,19 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= )} - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx index 7bd786290a..9dacd8d8cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' import { Home } from '@/app/workspace/[workspaceId]/home/home' +import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' export const metadata: Metadata = { title: 'Chat', @@ -11,22 +13,18 @@ interface ChatPageProps { workspaceId: string chatId: string }> - searchParams: Promise<{ resource?: string }> } -export default async function ChatPage({ params, searchParams }: ChatPageProps) { - const [{ chatId }, { resource }, session] = await Promise.all([ - params, - searchParams, - getSession(), - ]) +export default async function ChatPage({ params }: ChatPageProps) { + const [{ chatId }, session] = await Promise.all([params, getSession()]) return ( - + }> + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f6d8b19220..17a91222bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -3,7 +3,8 @@ import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { Button, @@ -73,6 +74,7 @@ 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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' @@ -171,9 +173,8 @@ export function Files() { const params = useParams() const router = useRouter() - const searchParams = useSearchParams() - const isNewFile = searchParams.get('new') === '1' - const currentFolderId = searchParams.get('folderId') + const [{ folderId: currentFolderId, new: isNewFile, shareFileId }, setFilesParams] = + useQueryStates(filesParsers, filesUrlKeys) const workspaceId = params?.workspaceId as string const posthog = usePostHog() @@ -271,7 +272,6 @@ export function Files() { folderIds: string[] name: string } | null>(null) - const [shareFileId, setShareFileId] = useState(null) const listRename = useInlineRename({ onSave: (rowId, name) => { @@ -303,7 +303,9 @@ export function Files() { const shareModal = shareFile ? ( !open && setShareFileId(null)} + onOpenChange={(open) => + !open && setFilesParams({ shareFileId: null }, { history: 'replace' }) + } workspaceId={workspaceId} fileId={shareFile.id} fileName={shareFile.name} @@ -995,8 +997,8 @@ export function Files() { const handleShareSelected = useCallback(() => { const file = selectedFileRef.current - if (file) setShareFileId(file.id) - }, []) + if (file) setFilesParams({ shareFileId: file.id }, { history: 'replace' }) + }, [setFilesParams]) const handleBulkDelete = useCallback(() => { if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return @@ -1204,7 +1206,7 @@ export function Files() { const item = contextMenuItemRef.current if (!item) return if (item.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${item.folder.id}`) + void setFilesParams({ folderId: item.folder.id, new: null }) closeContextMenu() return } @@ -1214,7 +1216,7 @@ export function Files() { : `/workspace/${workspaceId}/files/${item.file.id}` ) closeContextMenu() - }, [closeContextMenu, router, workspaceId]) + }, [closeContextMenu, router, workspaceId, setFilesParams]) const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current @@ -1244,9 +1246,9 @@ export function Files() { const handleContextMenuShare = useCallback(() => { const item = contextMenuItemRef.current - if (item?.kind === 'file') setShareFileId(item.file.id) + if (item?.kind === 'file') setFilesParams({ shareFileId: item.file.id }, { history: 'replace' }) closeContextMenu() - }, [closeContextMenu]) + }, [closeContextMenu, setFilesParams]) const handleContextMenuDelete = useCallback(() => { const item = contextMenuItemRef.current @@ -1517,7 +1519,7 @@ export function Files() { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { const parsed = parseRowId(rowId) if (parsed.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${parsed.id}`) + void setFilesParams({ folderId: parsed.id, new: null }) return } router.push( @@ -1527,7 +1529,7 @@ export function Files() { ) } }, - [router, workspaceId, currentFolderId] + [router, workspaceId, currentFolderId, setFilesParams] ) const handleUploadClick = useCallback(() => { @@ -1586,8 +1588,8 @@ export function Files() { ) const handleNavigateToFiles = useCallback(() => { - router.push(`/workspace/${workspaceId}/files`) - }, [router, workspaceId]) + void setFilesParams({ folderId: null, new: null }) + }, [setFilesParams]) const loadingBreadcrumbs = useMemo( (): BreadcrumbItem[] => [ @@ -1617,7 +1619,7 @@ export function Files() { label: folder.name, onClick: isCurrentFolder ? undefined - : () => router.push(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + : () => void setFilesParams({ folderId: folder.id, new: null }), editing: isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id ? { @@ -1647,8 +1649,7 @@ export function Files() { currentFolderId, folders, handleNavigateToFiles, - router, - workspaceId, + setFilesParams, canEdit, userPermissions.isLoading, breadcrumbRename.editingId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index 514662f7a7..ab21f2f3b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -9,8 +9,9 @@ export const metadata: Metadata = { } /** - * Files page entry. `Files` reads `useSearchParams`, so it must sit under a - * Suspense boundary. The fallback renders the real chrome (header + options + + * Files page entry. `Files` reads URL query params via nuqs (which uses + * `useSearchParams` internally), so it must sit under a Suspense boundary. The + * fallback renders the real chrome (header + options + * table headers) so a suspend never shows a blank frame; the route-level * `loading.tsx` covers the navigation/chunk-load transition the same way. */ diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts new file mode 100644 index 0000000000..d1c35f80e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -0,0 +1,49 @@ +import { createParser, parseAsString } from 'nuqs/server' + +/** + * 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 + * existing shared links keep opening the editor in compose mode. + */ +const parseAsNewFlag = createParser({ + parse(value) { + return value === '1' || value === 'true' + }, + serialize(value) { + return value ? '1' : '' + }, +}) + +/** + * Co-located, typed URL query-param definitions for the Files feature. The + * client (`Files`) consumes this typed param definition as the single source of + * truth. + * + * - `folderId` is the currently open folder; it is shareable, bookmarkable, and + * navigations between folders belong in the browser history (`history: 'push'`, + * the group default). + * - `new` marks a freshly-created file so the editor opens in compose mode; it is + * read once on mount and stripped as the route stabilizes. + * - `shareFileId` deep-links a file's share dialog open. The modal opens when the + * id resolves to a loaded file; closing it clears the param. Opening and + * closing the modal use a per-call `{ history: 'replace' }` override so the + * dialog toggle does not pollute the back/forward stack (a deep link still + * opens it on load). + */ +export const filesParsers = { + folderId: parseAsString, + new: parseAsNewFlag.withDefault(false), + shareFileId: parseAsString, +} as const + +/** + * Shared nuqs options for files query state. Folder navigation is a destination, + * so the group default lands in the browser history; defaults clear from the URL + * to keep links clean. Non-navigation writes (the `shareFileId` modal toggle) + * pass a per-call `{ history: 'replace' }` override so they don't add back-stack + * entries. + */ +export const filesUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/home-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/home/home-fallback.tsx new file mode 100644 index 0000000000..9c18b71993 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/home-fallback.tsx @@ -0,0 +1,10 @@ +/** + * Suspense fallback for the home/Chat surface. `Home` reads the `?resource=` + * URL param via nuqs (`useQueryState`, which uses `useSearchParams` + * internally), so it must sit under a Suspense boundary. This renders the + * surface background so a suspend never flashes a blank frame before the chat + * mounts. + */ +export function HomeFallback() { + return
+} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 4e21e754eb..9b85fefbec 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -1,8 +1,17 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' +import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { Button } from '@/components/emcn' import { PanelLeft } from '@/components/emcn/icons' @@ -25,6 +34,7 @@ import { } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead, @@ -53,13 +63,52 @@ interface HomeProps { chatId?: string userName?: string userId?: string - initialResourceId?: string | null } -export function Home({ chatId, userName, userId, initialResourceId = null }: HomeProps) { +export function Home({ chatId, userName, userId }: HomeProps) { useOAuthReturnRouter() const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() + /** + * URL is the single source of truth for the selected resource. `Home` renders + * client-side, so nuqs reads `?resource=` from the URL on mount — the same + * value the page previously threaded through `initialResourceId` — and writes + * it back with `history: 'replace'`, the previous behavior, minus the banned + * `window.history.replaceState` param-mutation effect. The page wraps `Home` + * in Suspense for the `useSearchParams` requirement. + */ + const [activeResourceParam, setResourceParam] = useQueryState(resourceParam.key, { + ...resourceParam.parser, + ...resourceUrlKeys, + }) + /** + * Strips any leftover URL fragment on selection change, preserving the old + * effect's `url.hash = ''` (the only hash usage on this surface) without a + * separate effect-sync mirror. This rewrites the fragment only — it never + * mutates a query param via the History API. + * + * Order matters: the fragment is stripped synchronously BEFORE the nuqs write, + * because nuqs re-appends `location.hash` on its (deferred) flush — clearing the + * hash first ensures the param write doesn't carry the stale fragment back. + */ + const setActiveResourceUrl = useCallback>>( + (action) => { + if (typeof window !== 'undefined' && window.location.hash) { + const { pathname, search } = window.location + window.history.replaceState(window.history.state, '', `${pathname}${search}`) + } + void setResourceParam(action) + }, + [setResourceParam] + ) + /** + * Controlled binding handed to `useChat` so the URL is the sole owner of the + * selection with no dual source. + */ + const activeResourceState = useMemo<[string | null, Dispatch>]>( + () => [activeResourceParam, setActiveResourceUrl], + [activeResourceParam, setActiveResourceUrl] + ) const firstName = userName?.split(' ')[0] ?? '' const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId) const { data: workflows = [] } = useWorkflows(workspaceId) @@ -179,7 +228,7 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom chatId, getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent, - initialActiveResourceId: initialResourceId, + activeResourceState, onRequestStarted: ({ requestId, userMessageId }) => { captureEvent(posthogRef.current, 'task_request_started', { workspace_id: workspaceId, @@ -191,17 +240,6 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom }) ) - useEffect(() => { - const url = new URL(window.location.href) - if (activeResourceId) { - url.searchParams.set('resource', activeResourceId) - } else { - url.searchParams.delete('resource') - } - url.hash = '' - window.history.replaceState(null, '', url.toString()) - }, [activeResourceId]) - useEffect(() => { wasSendingRef.current = false if (resolvedChatId) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 4a56b4cd94..b1ec30520a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' @@ -987,6 +995,17 @@ export interface UseChatOptions { onTitleUpdate?: () => void onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void initialActiveResourceId?: string | null + /** + * Controlled binding for the active resource id, supplied as a + * `[value, setValue]` tuple (e.g. a URL-backed nuqs `useQueryState`). When + * provided, it is the single source of truth for the selected resource — the + * hook reads and writes it directly instead of owning the state internally, + * so no effect-sync mirror is needed. When omitted, `useChat` owns the state + * via local `useState` (seeded from `initialActiveResourceId`); this is the + * mode used by the socket-synced workflow editor copilot, whose resource + * selection intentionally stays out of the URL. + */ + activeResourceState?: [string | null, Dispatch>] /** Fired when the server's `traceparent` response header arrives, before any stream content. */ onRequestStarted?: (info: { requestId: string; userMessageId: string }) => void } @@ -1006,7 +1025,11 @@ interface StopGenerationOptions { export function getMothershipUseChatOptions( options: Pick< UseChatOptions, - 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId' | 'onRequestStarted' + | 'onResourceEvent' + | 'onStreamEnd' + | 'initialActiveResourceId' + | 'activeResourceState' + | 'onRequestStarted' > = {} ): UseChatOptions { return { @@ -1044,9 +1067,16 @@ export function useChat( const [resolvedChatId, setResolvedChatId] = useState(initialChatId) const [queuedHandoffRecoveryEpoch, setQueuedHandoffRecoveryEpoch] = useState(0) const [resources, setResources] = useState([]) - const [activeResourceId, setActiveResourceId] = useState( + const internalActiveResourceState = useState( options?.initialActiveResourceId ?? null ) + /** + * Prefer a caller-supplied controlled binding (URL-backed nuqs on the home/Chat + * surface) so the URL is the single source of truth; fall back to internal state + * for the workflow editor copilot, which keeps resource selection out of the URL. + */ + const [activeResourceId, setActiveResourceId] = + options?.activeResourceState ?? internalActiveResourceState const [genericResourceData, setGenericResourceData] = useState(null) const onResourceEventRef = useRef(options?.onResourceEvent) const revealedSimKeysRef = useRef(new Map()) diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index e70b2fad1f..e29acc640e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,22 +1,18 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' import { Home } from './home' +import { HomeFallback } from './home-fallback' export const metadata: Metadata = { title: 'New chat', } -interface HomePageProps { - searchParams: Promise<{ resource?: string }> -} - -export default async function HomePage({ searchParams }: HomePageProps) { - const [session, { resource }] = await Promise.all([getSession(), searchParams]) +export default async function HomePage() { + const session = await getSession() return ( - + }> + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts new file mode 100644 index 0000000000..ef850466f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -0,0 +1,27 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definition for the home/Chat surface. + * + * `resource` deep-links the resource panel to the selected resource. The active + * resource id is the single source of truth for which resource the panel shows; + * `useChat` reads and writes it through this param, and the effective selection + * is derived against the loaded resource list (an unknown/stale id falls back to + * the last resource). The URL key is `resource` — existing shared links depend on + * it, so it must not be renamed. + */ +export const resourceParam = { + key: 'resource', + parser: parseAsString, +} as const + +/** + * Selecting a resource is a filter-like view change, not back-stack navigation, + * so it replaces the current history entry (matching the previous + * `window.history.replaceState` behavior). `clearOnDefault` drops the key from + * the URL when no resource is active. + */ +export const resourceUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index f16dbf2b10..3a2d73cf79 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -3,7 +3,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { useQueryState } from 'nuqs' import { Chip, ChipDropdown, ChipLink } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { @@ -14,13 +15,11 @@ import { import { getServiceConfigByProviderId } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' +import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { - CONNECT_MODE, - CONNECT_QUERY_PARAM, -} from '@/app/workspace/[workspaceId]/integrations/connect-route' +import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { getSuggestedSkillsForBlock, @@ -47,8 +46,7 @@ interface IntegrationBlockDetailProps { export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { useOAuthReturnRouter() const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() + const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser) const Icon = blockTypeToIconMap[integration.type] const matchingTemplates = getTemplatesForBlock(integration.type) const suggestedSkills = getSuggestedSkillsForBlock(integration.type) @@ -75,25 +73,24 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration useEffect(() => { if (hasHandledConnectQueryRef.current) return - const connect = searchParams.get(CONNECT_QUERY_PARAM) - if (!connect) return + if (!connectMode) return let handled = false - if (connect === CONNECT_MODE.oauth && oauthService) { + if (connectMode === CONNECT_MODE.oauth && oauthService) { setOAuthOpen(true) handled = true - } else if (connect === CONNECT_MODE.serviceAccount && oauthService?.serviceAccountProviderId) { + } else if ( + connectMode === CONNECT_MODE.serviceAccount && + oauthService?.serviceAccountProviderId + ) { setServiceAccountOpen(true) handled = true } if (!handled) return hasHandledConnectQueryRef.current = true - const params = new URLSearchParams(searchParams.toString()) - params.delete(CONNECT_QUERY_PARAM) - const qs = params.toString() - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) - }, [searchParams, oauthService, pathname, router]) + void setConnectMode(null, { history: 'replace', scroll: false }) + }, [connectMode, oauthService, setConnectMode]) const connectOptions = oauthService ? [ diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx index 6826faaeec..d8baabd34e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx @@ -1,5 +1,8 @@ +import { Suspense } from 'react' +import { ArrowLeft } from 'lucide-react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' +import { ChipLink } from '@/components/emcn' import { INTEGRATIONS } from '@/lib/integrations' import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' @@ -24,5 +27,19 @@ export default async function IntegrationBlockPage({ const integration = INTEGRATIONS.find((i) => i.slug === block) if (!integration) notFound() - return + return ( + +
+ + Integrations + +
+
+ } + > + +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts new file mode 100644 index 0000000000..c5c50fd6a0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts @@ -0,0 +1,17 @@ +import { parseAsStringLiteral } from 'nuqs/server' +import { + CONNECT_MODE, + CONNECT_QUERY_PARAM, +} from '@/app/workspace/[workspaceId]/integrations/connect-route' + +const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const + +/** + * Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on + * the integration detail page. The param is read once to pre-open the matching + * connect modal, then stripped from the URL. + */ +export const connectParam = { + key: CONNECT_QUERY_PARAM, + parser: parseAsStringLiteral(CONNECT_MODE_VALUES), +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 6c077e94dd..43e4331d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -1,8 +1,9 @@ 'use client' -import { type ComponentType, useMemo, useState } from 'react' +import { type ComponentType, useCallback, useMemo } from 'react' import Link from 'next/link' import { useParams } from 'next/navigation' +import { debounce, useQueryStates } from 'nuqs' import { ArrowRight, ChevronDown, @@ -25,11 +26,18 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { + ALL_CATEGORY, + CONNECTED_LABEL, + FEATURED_LABEL, + integrationsParsers, + integrationsUrlKeys, +} from '@/app/workspace/[workspaceId]/integrations/search-params' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' -const ALL_CATEGORY = 'All' -const FEATURED_LABEL = 'Featured' -const CONNECTED_LABEL = 'Connected' +/** Debounce window for `search` URL writes; the input itself stays instant. */ +const SEARCH_DEBOUNCE_MS = 300 as const + /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const @@ -130,8 +138,25 @@ export function Integrations() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useState('') - const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY) + const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = + useQueryStates(integrationsParsers, integrationsUrlKeys) + + /** + * 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. + */ + 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 { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ workspaceId, @@ -164,6 +189,13 @@ export function Integrations() { }) }, [oauthCredentials]) + const setSelectedCategory = useCallback( + (category: string) => { + setIntegrationFilters({ category }) + }, + [setIntegrationFilters] + ) + const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), @@ -179,7 +211,7 @@ export function Integrations() { // Connected-only view: integration sections are suppressed entirely. if (isConnectedSelected) return [] - const normalizedSearch = searchTerm.trim().toLowerCase() + const normalizedSearch = urlSearchTerm.trim().toLowerCase() const matchesSearch = (integration: Integration) => !normalizedSearch || integration.name.toLowerCase().includes(normalizedSearch) || @@ -215,14 +247,20 @@ export function Integrations() { ...featuredSection, ...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []), ] - }, [isAllCategorySelected, isConnectedSelected, isFeaturedSelected, searchTerm, selectedCategory]) + }, [ + isAllCategorySelected, + isConnectedSelected, + isFeaturedSelected, + urlSearchTerm, + selectedCategory, + ]) const visibleConnectedItems = useMemo(() => { // Featured-only view: Connected is suppressed (mirror behavior of the // Featured-only branch above, which renders only the Featured section). if (isFeaturedSelected) return [] - const normalizedSearch = searchTerm.trim().toLowerCase() + const normalizedSearch = urlSearchTerm.trim().toLowerCase() return connectedItems.filter((item) => { const matchesCategory = isAllCategorySelected || isConnectedSelected || item.integrationType === selectedCategory @@ -239,12 +277,12 @@ export function Integrations() { isAllCategorySelected, isConnectedSelected, isFeaturedSelected, - searchTerm, + urlSearchTerm, selectedCategory, ]) const showNoResults = - Boolean(searchTerm.trim() || !isAllCategorySelected) && + Boolean(urlSearchTerm.trim() || !isAllCategorySelected) && filteredCategorySections.length === 0 && visibleConnectedItems.length === 0 @@ -259,7 +297,7 @@ export function Integrations() { icon={Search} className='min-w-0 flex-1' placeholder='Search integrations...' - value={searchTerm} + value={urlSearchTerm} onChange={(e) => setSearchTerm(e.target.value)} disabled={credentialsLoading} /> @@ -322,8 +360,8 @@ export function Integrations() { {showNoResults && (
- {searchTerm.trim() - ? `No integrations found matching “${searchTerm}”` + {urlSearchTerm.trim() + ? `No integrations found matching “${urlSearchTerm}”` : 'No integrations in this category'}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx index 81d4fe3a38..55b28b5eea 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx @@ -1,8 +1,34 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' +import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { Integrations } from '@/app/workspace/[workspaceId]/integrations/integrations' export const metadata: Metadata = { title: 'Integrations', } -export default Integrations +/** + * Integrations page entry. `Integrations` reads URL query params via nuqs (which + * uses `useSearchParams` internally), so it must sit under a Suspense boundary. + * The fallback renders the real page chrome (background + tab header) so a + * suspend never shows a blank frame. + */ +export default async function IntegrationsPage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + + return ( + + + + } + > + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts new file mode 100644 index 0000000000..f03533261f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts @@ -0,0 +1,30 @@ +import { parseAsString } from 'nuqs/server' + +/** Default category — the unfiltered "All" view. */ +export const ALL_CATEGORY = 'All' +/** Pinned, curated home-row section. */ +export const FEATURED_LABEL = 'Featured' +/** Connected-credentials section (only shown when the user has connections). */ +export const CONNECTED_LABEL = 'Connected' + +/** + * Co-located, typed URL query-param definitions for the Integrations gallery. + * + * - `category` selects the active integration category tab. Categories mix the + * `IntegrationType` enum values with the `All`/`Featured`/`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. + */ +export const integrationsParsers = { + category: parseAsString.withDefault(ALL_CATEGORY), + search: parseAsString.withDefault(''), +} as const + +/** Filter/search view-state: clean URLs, no back-stack churn. */ +export const integrationsUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const 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 2266d160c7..2e2cb52e2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@/components/emcn' import { Database } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' @@ -31,6 +32,10 @@ import { DeleteChunkModal, DocumentTagsModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' +import { + documentParsers, + documentUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -121,8 +126,10 @@ export function Document({ }: DocumentProps) { const { workspaceId } = useParams() const router = useRouter() - const searchParams = useSearchParams() - const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10) + const [{ page: currentPageFromURL, chunk: chunkFromURL }, setDocumentParams] = useQueryStates( + documentParsers, + documentUrlKeys + ) const userPermissions = useUserPermissionsContext() const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) @@ -181,9 +188,18 @@ export function Document({ const [selectedChunks, setSelectedChunks] = useState>(() => new Set()) - // Inline editor state - const [selectedChunkId, setSelectedChunkId] = useState(() => - searchParams.get('chunk') + /** + * Inline editor state. The open chunk is sourced directly from the URL `chunk` + * param (single source of truth) so back/forward, deep links, and external + * navigation drive the editor; opening/closing a chunk writes the param. + */ + const selectedChunkId = chunkFromURL + /** Opening a chunk is a destination (back closes it); clearing replaces. */ + const setSelectedChunkId = useCallback( + (chunkId: string | null) => { + void setDocumentParams({ chunk: chunkId }, chunkId !== null ? { history: 'push' } : undefined) + }, + [setDocumentParams] ) const [isCreatingNewChunk, setIsCreatingNewChunk] = useState(false) const [isDirty, setIsDirty] = useState(false) @@ -224,20 +240,14 @@ export function Document({ const goToPage = useCallback( async (page: number) => { - const params = new URLSearchParams(window.location.search) - if (page > 1) { - params.set('page', page.toString()) - } else { - params.delete('page') - } - window.history.replaceState(null, '', `?${params.toString()}`) + await setDocumentParams({ page }) if (showingSearch) { return } return initialGoToPage(page) }, - [showingSearch, initialGoToPage] + [showingSearch, initialGoToPage, setDocumentParams] ) const updateChunk = showingSearch @@ -304,7 +314,7 @@ export function Document({ setIsCreatingNewChunk(false) setIsDirty(false) setSaveStatus('idle') - }, []) + }, [setSelectedChunkId]) const guardDirtyAction = useCallback( (action: () => void) => { @@ -421,7 +431,15 @@ export function Document({ } } }, - [selectedChunk, currentChunkIndex, displayChunks, currentPage, totalPages, goToPage] + [ + selectedChunk, + currentChunkIndex, + displayChunks, + currentPage, + totalPages, + goToPage, + setSelectedChunkId, + ] ) const handleNavigateChunk = useCallback( @@ -445,7 +463,7 @@ export function Document({ const handleShowTags = useCallback(() => setShowTagsModal(true), []) const handleShowDeleteDoc = useCallback(() => setShowDeleteDocumentDialog(true), []) - const handleClearSelectedChunk = useCallback(() => setSelectedChunkId(null), []) + const handleClearSelectedChunk = useCallback(() => setSelectedChunkId(null), [setSelectedChunkId]) const breadcrumbs = useMemo( () => @@ -517,7 +535,7 @@ export function Document({ setIsDirty(false) setSaveStatus('idle') }) - }, [guardDirtyAction]) + }, [guardDirtyAction, setSelectedChunkId]) const handleChunkCreated = useCallback( async (chunkId: string) => { @@ -550,7 +568,7 @@ export function Document({ } setTimeout(checkAndSelect, 0) }, - [goToPage, totalPages] + [goToPage, totalPages, setSelectedChunkId] ) const createAction = useMemo( @@ -635,9 +653,12 @@ export function Document({ [enabledFilter, goToPage] ) - const handleChunkClick = useCallback((rowId: string) => { - setSelectedChunkId(rowId) - }, []) + const handleChunkClick = useCallback( + (rowId: string) => { + setSelectedChunkId(rowId) + }, + [setSelectedChunkId] + ) const handleToggleEnabled = useCallback( (chunkId: string) => { 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 new file mode 100644 index 0000000000..a2a7fb8934 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -0,0 +1,26 @@ +import { parseAsInteger, parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the knowledge document + * (chunk list + inline chunk editor) page. The client (`Document`) consumes this + * typed param definition as the single source of truth. + * + * - `page` is the chunk pagination page, shareable and bookmarkable. It defaults + * 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. + */ +export const documentParsers = { + page: parseAsInteger.withDefault(1), + chunk: parseAsString, +} as const + +/** + * Shared nuqs options for the document page. Pagination is a transient view + * change, so it replaces history rather than churning the back stack; defaults + * clear from the URL. + */ +export const documentUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 51572bc459..46458b43d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -6,7 +6,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { format } from 'date-fns' import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react' -import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { debounce, useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { Badge, @@ -31,7 +32,6 @@ import { import { Database, DatabaseX } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' import { cn } from '@/lib/core/utils/cn' -import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' @@ -58,6 +58,16 @@ import { DocumentContextMenu, RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { + addConnectorParam, + DEFAULT_KB_SORT_COLUMN, + DEFAULT_KB_SORT_DIRECTION, + documentFiltersParsers, + documentFiltersUrlKeys, + type KbSortColumn, + pageParam, + pageUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -80,6 +90,7 @@ import { useUpdateDocument, useUpdateKnowledgeBase, } from '@/hooks/queries/kb/knowledge' +import { useDebounce } from '@/hooks/use-debounce' import { useInlineRename } from '@/hooks/use-inline-rename' import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' @@ -208,9 +219,10 @@ export function KnowledgeBase({ const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) const router = useRouter() - const searchParams = useSearchParams() - const pathname = usePathname() - const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM) + const [addConnectorType, setAddConnectorType] = useQueryState( + addConnectorParam.key, + addConnectorParam.parser + ) const posthog = usePostHog() useEffect(() => { @@ -236,9 +248,7 @@ export function KnowledgeBase({ }) const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation() - const [searchQuery, setSearchQuery] = useState('') const [showTagsModal, setShowTagsModal] = useState(false) - const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all') const [tagFilterEntries, setTagFilterEntries] = useState< { id: string @@ -265,11 +275,6 @@ export function KnowledgeBase({ [tagFilterEntries] ) - const handleSearchChange = useCallback((newQuery: string) => { - setSearchQuery(newQuery) - setCurrentPage(1) - }, []) - const [selectedDocuments, setSelectedDocuments] = useState>(() => new Set()) const [isSelectAllMode, setIsSelectAllMode] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -278,32 +283,64 @@ export function KnowledgeBase({ const [documentToDelete, setDocumentToDelete] = useState(null) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [showConnectorsModal, setShowConnectorsModal] = useState(false) - const [currentPage, setCurrentPage] = useState(1) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) + const [currentPage, setCurrentPage] = useQueryState(pageParam.key, { + ...pageParam.parser, + ...pageUrlKeys, + }) + + const [ + { q: searchQuery, enabled: enabledFilter, sort: sortColumn, dir: sortDirection }, + 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) + + /** + * 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 setEnabledFilter = useCallback( + (value: 'all' | 'enabled' | 'disabled') => { + setDocumentFilters({ enabled: value }) + setCurrentPage(1) + }, + [setDocumentFilters, setCurrentPage] + ) + const [contextMenuDocument, setContextMenuDocument] = useState(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) - const showAddConnectorModal = addConnectorParam != null - const searchParamsRef = useRef(searchParams) - searchParamsRef.current = searchParams + const showAddConnectorModal = addConnectorType != null const updateAddConnectorParam = useCallback( (value: string | null) => { - const current = searchParamsRef.current - const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM) - if (value === currentValue || (value === null && currentValue === null)) return - const next = new URLSearchParams(current.toString()) - if (value === null) { - next.delete(ADD_CONNECTOR_SEARCH_PARAM) - } else { - next.set(ADD_CONNECTOR_SEARCH_PARAM, value) - } - const qs = next.toString() - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) + void setAddConnectorType(value, { history: 'replace', scroll: false }) }, - [pathname, router] + [setAddConnectorType] ) const setShowAddConnectorModal = useCallback( (open: boolean) => updateAddConnectorParam(open ? '' : null), @@ -338,11 +375,11 @@ export function KnowledgeBase({ updateDocument, refreshDocuments, } = useKnowledgeBaseDocuments(id, { - search: searchQuery || undefined, + search: debouncedSearchQuery || undefined, limit: DOCUMENTS_PER_PAGE, offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, - sortBy: (activeSort?.column ?? 'uploadedAt') as DocumentSortField, - sortOrder: (activeSort?.direction ?? 'desc') as SortOrder, + sortBy: sortColumn as DocumentSortField, + sortOrder: sortDirection as SortOrder, refetchInterval: (data) => { if (isDeleting) return false const hasPending = data?.documents?.some( @@ -860,15 +897,19 @@ export function KnowledgeBase({ ], active: activeSort, onSort: (column, direction) => { - setActiveSort({ column, direction }) + setDocumentFilters({ sort: column as KbSortColumn, dir: 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: () => { - setActiveSort(null) + setDocumentFilters({ sort: DEFAULT_KB_SORT_COLUMN, dir: DEFAULT_KB_SORT_DIRECTION }) setCurrentPage(1) }, }), - [activeSort] + [activeSort, setDocumentFilters, setCurrentPage] ) const filterContent = useMemo( @@ -882,7 +923,6 @@ export function KnowledgeBase({ variant='ghost' onClick={() => { setEnabledFilter('all') - setCurrentPage(1) setSelectedDocuments(new Set()) setIsSelectAllMode(false) }} @@ -898,7 +938,6 @@ export function KnowledgeBase({ onChange={(value) => { if (value !== 'all' && value !== 'enabled' && value !== 'disabled') return setEnabledFilter(value) - setCurrentPage(1) setSelectedDocuments(new Set()) setIsSelectAllMode(false) }} @@ -971,7 +1010,6 @@ export function KnowledgeBase({ label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, onRemove: () => { setEnabledFilter('all') - setCurrentPage(1) setSelectedDocuments(new Set()) setIsSelectAllMode(false) }, @@ -1284,7 +1322,7 @@ export function KnowledgeBase({ onOpenChange={setShowAddConnectorModal} onConnectorTypeChange={updateAddConnectorParam} knowledgeBaseId={id} - initialConnectorType={addConnectorParam || undefined} + initialConnectorType={addConnectorType || undefined} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts new file mode 100644 index 0000000000..39a57eddc8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -0,0 +1,82 @@ +import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' + +/** + * Co-located, typed URL query-param definitions for the knowledge base detail + * page. The client (`KnowledgeBase`) consumes this typed param definition as the + * single source of truth. + * + * `addConnector` is a deep-link that pre-opens the "add connector" modal. Its + * presence (even as an empty string) opens the modal; its value seeds the + * initial connector type. Mirrors the integrations `connect` deep-link pattern. + */ +export const addConnectorParam = { + key: ADD_CONNECTOR_SEARCH_PARAM, + parser: parseAsString, +} as const + +/** + * `page` is the 1-based document-list pagination index for this knowledge base. + * Distinct from the single-document subview's `page` (a different route). The + * default page (1) clears from the URL. + */ +export const pageParam = { + key: 'page', + parser: parseAsInteger.withDefault(1), +} as const + +/** Pagination view-state: clean URLs, no back-stack churn. */ +export const pageUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** Document `enabled` filter buckets, matching the status filter dropdown. */ +const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const + +/** Sortable document columns, matching the `Resource` sort menu / `DocumentSortField`. */ +export const KB_SORT_COLUMNS = [ + 'filename', + 'fileSize', + 'tokenCount', + 'chunkCount', + 'uploadedAt', + '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' + +/** + * Grouped filter/search/sort 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. + * - `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 + * row), too large/structured for the URL per the URL-state doctrine. It stays + * in local `useState`. + */ +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. */ +export const documentFiltersUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 38ba313949..ce53588e76 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -2,16 +2,15 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useParams } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' import { Loader } from '@/components/emcn' import { DashboardSegmentsContext, type SegmentSelectionMode, } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard-segments-context' +import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFilterStore } from '@/stores/logs/filters/store' import { LineChart, WorkflowsList } from './components' interface WorkflowExecution { @@ -39,6 +38,12 @@ interface DashboardProps { stats?: DashboardStatsResponse isLoading: boolean error?: Error | null + /** + * Debounced search term. Comes pre-debounced from the parent (same value the + * dashboard stats query uses) so the in-memory workflow list filtering and the + * stats query stay in sync while typing. + */ + searchQuery: string } /** @@ -61,19 +66,12 @@ function toWorkflowExecution(wf: WorkflowStats): WorkflowExecution { } } -function DashboardInner({ stats, isLoading, error }: DashboardProps) { +function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps) { const [selectedSegments, setSelectedSegments] = useState>({}) const [lastAnchorIndices, setLastAnchorIndices] = useState>({}) const lastAnchorIndicesRef = useRef>({}) - const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore( - useShallow((s) => ({ - workflowIds: s.workflowIds, - searchQuery: s.searchQuery, - toggleWorkflowId: s.toggleWorkflowId, - timeRange: s.timeRange, - })) - ) + const { workflowIds, toggleWorkflowId, timeRange } = useLogFilters() const { workspaceId } = useParams<{ workspaceId: string }>() const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 0078febbf5..37a63dd28d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { formatDuration } from '@sim/utils/formatting' import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react' +import { useQueryState } from 'nuqs' import { createPortal } from 'react-dom' import { Button, @@ -34,6 +35,10 @@ import { TraceView, } from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' +import { + logDetailsTabParam, + logDetailsTabUrlKeys, +} from '@/app/workspace/[workspaceId]/logs/search-params' import { DELETED_WORKFLOW_LABEL, formatDate, @@ -262,23 +267,31 @@ interface LogDetailsContentProps { export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) - const [activeTab, setActiveTab] = useState('overview') - const [prevLogId, setPrevLogId] = useState(log.id) + const [activeTab, setActiveTab] = useQueryState(logDetailsTabParam.key, { + ...logDetailsTabParam.parser, + ...logDetailsTabUrlKeys, + }) const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 }) - if (prevLogId !== log.id) { - setPrevLogId(log.id) - setActiveTab('overview') - } - const scrollAreaRef = useRef(null) const { config: permissionConfig } = usePermissionConfig() + const isInitialTabMountRef = useRef(true) + /** + * Honors a deep-linked tab on first mount; resets to overview only when + * switching to a different log. + */ useEffect(() => { + if (isInitialTabMountRef.current) { + isInitialTabMountRef.current = false + } else { + setActiveTab('overview') + } if (scrollAreaRef.current) { scrollAreaRef.current.scrollTop = 0 } + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable nuqs setter; reset tab when switching logs }, [log.id]) const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index f628b636b9..b173d8e127 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -4,7 +4,6 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { ArrowUp, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { useShallow } from 'zustand/react/shallow' import { Button, ChipCombobox, @@ -21,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn' import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { captureEvent } from '@/lib/posthog/client' +import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { formatDateShort, type LogStatus, @@ -29,7 +29,6 @@ import { import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { AutocompleteSearch } from './components/search' @@ -181,25 +180,7 @@ export const LogsToolbar = memo(function LogsToolbar({ setDateRange, clearDateRange, resetFilters, - } = useFilterStore( - useShallow((s) => ({ - level: s.level, - setLevel: s.setLevel, - workflowIds: s.workflowIds, - setWorkflowIds: s.setWorkflowIds, - folderIds: s.folderIds, - setFolderIds: s.setFolderIds, - triggers: s.triggers, - setTriggers: s.setTriggers, - timeRange: s.timeRange, - setTimeRange: s.setTimeRange, - startDate: s.startDate, - endDate: s.endDate, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - resetFilters: s.resetFilters, - })) - ) + } = useLogFilters() const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts new file mode 100644 index 0000000000..f8cf3ad8fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts @@ -0,0 +1,206 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { debounce, useQueryStates } from 'nuqs' +import { + logFilterParsers, + logFilterUrlKeys, +} from '@/app/workspace/[workspaceId]/logs/search-params' +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. + * + * This replaces the former hand-rolled `useFilterStore` URL sync (`syncWithURL` + * / `initializeFromURL` / `popstate`). The URL is now the single source of + * truth — initialization, serialization, and back/forward navigation are all + * handled by nuqs. The action surface mirrors the previous store so consumers + * migrate with minimal churn. + */ +export interface UseLogFilters { + timeRange: TimeRange + startDate: string | undefined + endDate: string | undefined + level: LogLevel + workflowIds: string[] + folderIds: string[] + triggers: TriggerType[] + searchQuery: string + + setTimeRange: (timeRange: TimeRange) => void + setDateRange: (startDate: string | undefined, endDate: string | undefined) => void + clearDateRange: () => void + setLevel: (level: LogLevel) => void + setWorkflowIds: (workflowIds: string[]) => void + toggleWorkflowId: (workflowId: string) => void + setFolderIds: (folderIds: string[]) => void + toggleFolderId: (folderId: string) => void + setSearchQuery: (query: string) => void + setTriggers: (triggers: TriggerType[]) => void + toggleTrigger: (trigger: TriggerType) => void + resetFilters: () => void +} + +/** + * Hook exposing the logs filter state and actions backed by URL query params. + * `startDate`/`endDate` are only retained while the time range is "Custom range" + * to match the prior store semantics. + */ +export function useLogFilters(): UseLogFilters { + const [filters, setFilters] = useQueryStates(logFilterParsers, logFilterUrlKeys) + + const setTimeRange = useCallback( + (timeRange: TimeRange) => { + if (timeRange === 'Custom range') { + setFilters({ timeRange }) + } else { + setFilters({ timeRange, startDate: null, endDate: null }) + } + }, + [setFilters] + ) + + const setDateRange = useCallback( + (startDate: string | undefined, endDate: string | undefined) => { + setFilters({ + timeRange: 'Custom range', + startDate: startDate ?? null, + endDate: endDate ?? null, + }) + }, + [setFilters] + ) + + const clearDateRange = useCallback(() => { + setFilters({ timeRange: DEFAULT_TIME_RANGE, startDate: null, endDate: null }) + }, [setFilters]) + + const setLevel = useCallback((level: LogLevel) => setFilters({ level }), [setFilters]) + + const setWorkflowIds = useCallback( + (workflowIds: string[]) => setFilters({ workflowIds }), + [setFilters] + ) + + const toggleWorkflowId = useCallback( + (workflowId: string) => { + setFilters((prev) => { + const current = prev.workflowIds + const next = current.includes(workflowId) + ? current.filter((id) => id !== workflowId) + : [...current, workflowId] + return { workflowIds: next } + }) + }, + [setFilters] + ) + + const setFolderIds = useCallback((folderIds: string[]) => setFilters({ folderIds }), [setFilters]) + + const toggleFolderId = useCallback( + (folderId: string) => { + setFilters((prev) => { + const current = prev.folderIds + const next = current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId] + return { folderIds: next } + }) + }, + [setFilters] + ) + + /** + * 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. + */ + 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 setTriggers = useCallback( + (triggers: TriggerType[]) => setFilters({ triggers }), + [setFilters] + ) + + const toggleTrigger = useCallback( + (trigger: TriggerType) => { + setFilters((prev) => { + const current = prev.triggers + const next = current.includes(trigger) + ? current.filter((t) => t !== trigger) + : [...current, trigger] + return { triggers: next } + }) + }, + [setFilters] + ) + + const resetFilters = useCallback(() => { + setFilters({ + timeRange: DEFAULT_TIME_RANGE, + startDate: null, + endDate: null, + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + search: null, + }) + }, [setFilters]) + + return useMemo( + () => ({ + timeRange: filters.timeRange, + startDate: + filters.timeRange === 'Custom range' ? (filters.startDate ?? undefined) : undefined, + endDate: filters.timeRange === 'Custom range' ? (filters.endDate ?? undefined) : undefined, + level: filters.level, + workflowIds: filters.workflowIds, + folderIds: filters.folderIds, + triggers: filters.triggers, + searchQuery: filters.search, + setTimeRange, + setDateRange, + clearDateRange, + setLevel, + setWorkflowIds, + toggleWorkflowId, + setFolderIds, + toggleFolderId, + setSearchQuery, + setTriggers, + toggleTrigger, + resetFilters, + }), + [ + filters, + setTimeRange, + setDateRange, + clearDateRange, + setLevel, + setWorkflowIds, + toggleWorkflowId, + setFolderIds, + toggleFolderId, + setSearchQuery, + setTriggers, + toggleTrigger, + resetFilters, + ] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index df99b7cb09..559824dc24 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -12,7 +12,7 @@ import { import { formatDuration } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' +import { useQueryState } from 'nuqs' import { Button, ChipCombobox, @@ -52,7 +52,13 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { Resource } from '@/app/workspace/[workspaceId]/components' +import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' +import { + executionIdParam, + logDetailsTabParam, + logDetailsTabUrlKeys, +} 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' @@ -200,14 +206,7 @@ export default function Logs() { const params = useParams() const workspaceId = params.workspaceId as string - useState(() => { - useFilterStore.getState().initializeFromURL() - return null - }) - const { - setWorkspaceId, - initializeFromURL, timeRange, startDate, endDate, @@ -215,10 +214,9 @@ export default function Logs() { workflowIds, folderIds, setWorkflowIds, - setSearchQuery: setStoreSearchQuery, + searchQuery: urlSearchQuery, + setSearchQuery: setUrlSearchQuery, triggers, - viewMode, - setViewMode, resetFilters, setLevel, setFolderIds, @@ -226,50 +224,35 @@ export default function Logs() { setTimeRange, setDateRange, clearDateRange, - } = useFilterStore( - useShallow((s) => ({ - setWorkspaceId: s.setWorkspaceId, - initializeFromURL: s.initializeFromURL, - timeRange: s.timeRange, - startDate: s.startDate, - endDate: s.endDate, - level: s.level, - workflowIds: s.workflowIds, - folderIds: s.folderIds, - setWorkflowIds: s.setWorkflowIds, - setSearchQuery: s.setSearchQuery, - triggers: s.triggers, - viewMode: s.viewMode, - setViewMode: s.setViewMode, - resetFilters: s.resetFilters, - setLevel: s.setLevel, - setFolderIds: s.setFolderIds, - setTriggers: s.setTriggers, - setTimeRange: s.setTimeRange, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - })) - ) + } = useLogFilters() - useEffect(() => { - setWorkspaceId(workspaceId) - }, [workspaceId, setWorkspaceId]) + const viewMode = useFilterStore((s) => s.viewMode) + const setViewMode = useFilterStore((s) => s.setViewMode) const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { selectedLogId: null, isSidebarOpen: false, }) - const [pendingExecutionId, setPendingExecutionId] = useState(() => - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('executionId') - : null - ) - const [searchQuery, setSearchQuery] = useState(() => { - if (typeof window === 'undefined') return '' - return new URLSearchParams(window.location.search).get('search') ?? '' + const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser) + const [pendingExecutionId, setPendingExecutionId] = useState(() => executionId) + + /** + * The log-details `tab` param is owned/written by the details panel, but the + * orchestrator must clear it when the panel closes so a lingering `?tab=trace` + * never carries over to the next log opened from the list. + */ + const [, setLogDetailsTab] = useQueryState(logDetailsTabParam.key, { + ...logDetailsTabParam.parser, + ...logDetailsTabUrlKeys, }) - const debouncedSearchQuery = useDebounce(searchQuery, 300) + + /** + * `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. + */ + const debouncedSearchQuery = useDebounce(urlSearchQuery, 300) const isLive = true const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) @@ -419,10 +402,6 @@ export default function Logs() { } }, []) - useEffect(() => { - setStoreSearchQuery(debouncedSearchQuery) - }, [debouncedSearchQuery, setStoreSearchQuery]) - const handleLogClick = useCallback((rowId: string) => { dispatch({ type: 'TOGGLE_LOG', logId: rowId }) }, []) @@ -449,6 +428,23 @@ export default function Logs() { activeLogTabRef.current = 'overview' }, []) + /** + * Strip the `tab` param whenever the detail panel transitions from open to + * closed — by the X button, toggling the same row, or the keyboard — so + * reopening another log starts on overview rather than inheriting the closed + * log's tab. Guarded on a prior-open ref so an initial deep-linked `?tab=` is + * preserved (the panel isn't open yet on first mount). + */ + const wasSidebarOpenRef = useRef(false) + useEffect(() => { + if (isSidebarOpen) { + wasSidebarOpenRef.current = true + } else if (wasSidebarOpenRef.current) { + wasSidebarOpenRef.current = false + setLogDetailsTab(null) + } + }, [isSidebarOpen, setLogDetailsTab]) + const handleActiveTabChange = useCallback((tab: string) => { activeLogTabRef.current = tab }, []) @@ -495,10 +491,13 @@ export default function Logs() { } }, [contextMenuLog, workflowIds, setWorkflowIds]) + /** + * `resetFilters()` already clears `search` (sets it to null), so no separate + * search reset is needed here. + */ const handleClearAllFilters = useCallback(() => { resetFilters() - setSearchQuery('') - }, [resetFilters, setSearchQuery]) + }, [resetFilters]) const handleOpenPreview = useCallback(() => { if (contextMenuLog?.id) { @@ -649,17 +648,6 @@ export default function Logs() { debouncedSearchQuery, ]) - useEffect(() => { - const handlePopState = () => { - initializeFromURL() - const params = new URLSearchParams(window.location.search) - setSearchQuery(params.get('search') || '') - } - - window.addEventListener('popstate', handlePopState) - return () => window.removeEventListener('popstate', handlePopState) - }, [initializeFromURL]) - const loadMoreLogs = useCallback(() => { const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!isFetching && hasNextPage) { @@ -866,13 +854,16 @@ export default function Logs() { [workflowsData, foldersData, triggersData] ) - const handleFiltersChange = useCallback((filters: ParsedFilter[], textSearch: string) => { - const filterStrings = filters.map( - (f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}` - ) - const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ') - setSearchQuery(fullQuery) - }, []) + const handleFiltersChange = useCallback( + (filters: ParsedFilter[], textSearch: string) => { + const filterStrings = filters.map( + (f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}` + ) + const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ') + setUrlSearchQuery(fullQuery) + }, + [setUrlSearchQuery] + ) const getSuggestions = useCallback( (input: string) => suggestionEngine.getSuggestions(input), @@ -906,14 +897,14 @@ export default function Logs() { const lastExternalSearchValue = useRef(undefined) useEffect(() => { - if (searchQuery === lastExternalSearchValue.current) return + if (urlSearchQuery === lastExternalSearchValue.current) return const isMount = lastExternalSearchValue.current === undefined - lastExternalSearchValue.current = searchQuery + lastExternalSearchValue.current = urlSearchQuery // On mount with no initial query, skip the no-op parse - if (isMount && !searchQuery) return - const parsed = parseQuery(searchQuery) + if (isMount && !urlSearchQuery) return + const parsed = parseQuery(urlSearchQuery) initializeFromQuery(parsed.textSearch, parsed.filters) - }, [searchQuery, initializeFromQuery]) + }, [urlSearchQuery, initializeFromQuery]) useEffect(() => { if (!isSuggestionsOpen || highlightedIndex < 0) return @@ -1109,7 +1100,10 @@ export default function Logs() { sort={sortConfig} filter={{ content: ( - + ), }} filterTags={filterTags} @@ -1121,6 +1115,7 @@ export default function Logs() { stats={dashboardStatsQuery.data} isLoading={dashboardStatsQuery.isLoading} error={dashboardStatsQuery.error} + searchQuery={debouncedSearchQuery} /> {sidebarOverlay} @@ -1197,25 +1192,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr setDateRange, clearDateRange, resetFilters, - } = useFilterStore( - useShallow((s) => ({ - level: s.level, - setLevel: s.setLevel, - workflowIds: s.workflowIds, - setWorkflowIds: s.setWorkflowIds, - folderIds: s.folderIds, - setFolderIds: s.setFolderIds, - triggers: s.triggers, - setTriggers: s.setTriggers, - timeRange: s.timeRange, - setTimeRange: s.setTimeRange, - startDate: s.startDate, - endDate: s.endDate, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - resetFilters: s.resetFilters, - })) - ) + } = useLogFilters() const [datePickerOpen, setDatePickerOpen] = useState(false) const previousTimeRangeRef = useRef(timeRange) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx index 0b0d49ff33..e24d82af83 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx @@ -1,8 +1,22 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' +import LogsLoading from '@/app/workspace/[workspaceId]/logs/loading' import Logs from '@/app/workspace/[workspaceId]/logs/logs' export const metadata: Metadata = { title: 'Logs', } -export default Logs +/** + * Logs page entry. `Logs` 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 function LogsPage() { + return ( + }> + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts new file mode 100644 index 0000000000..643e9f992c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -0,0 +1,145 @@ +import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { + CORE_TRIGGER_TYPES, + type LogLevel, + type TimeRange, + type TriggerType, +} from '@/stores/logs/filters/types' + +/** + * Co-located, typed URL query-param definitions for the logs feature. The + * client hook (`useLogFilters`) consumes this typed param definition as the + * 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. + */ + +const DEFAULT_TIME_RANGE: TimeRange = 'All time' + +/** Maps a {@link TimeRange} label to its stable URL token and back. */ +const TIME_RANGE_TO_TOKEN: Record = { + 'All time': 'all-time', + 'Past 30 minutes': 'past-30-minutes', + 'Past hour': 'past-hour', + 'Past 6 hours': 'past-6-hours', + 'Past 12 hours': 'past-12-hours', + 'Past 24 hours': 'past-24-hours', + 'Past 3 days': 'past-3-days', + 'Past 7 days': 'past-7-days', + 'Past 14 days': 'past-14-days', + 'Past 30 days': 'past-30-days', + 'Custom range': 'custom', +} + +const TOKEN_TO_TIME_RANGE: Record = Object.fromEntries( + Object.entries(TIME_RANGE_TO_TOKEN).map(([label, token]) => [token, label as TimeRange]) +) as Record + +/** + * Parser for the `timeRange` param. Serializes labels to kebab tokens and + * tolerantly maps unknown tokens back to the default ("All time"). + */ +export const parseAsTimeRange = createParser({ + parse(value) { + return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE + }, + serialize(value) { + return TIME_RANGE_TO_TOKEN[value] ?? 'all-time' + }, +}) + +const VALID_LEVELS = ['error', 'info', 'running', 'pending'] as const + +/** + * Parser for the `level` param. `level` is a comma-joined list of statuses on + * the wire but is surfaced as a single `LogLevel` value ("all", a single status, + * or a comma-joined string) to match the existing store contract. + */ +export const parseAsLogLevel = createParser({ + parse(value) { + const levels = value + .split(',') + .filter((l): l is (typeof VALID_LEVELS)[number] => + (VALID_LEVELS as readonly string[]).includes(l) + ) + if (levels.length === 0) return 'all' + if (levels.length === 1) return levels[0] + return levels.join(',') as LogLevel + }, + serialize(value) { + return value + }, +}) + +const CORE_TRIGGER_SET = new Set(CORE_TRIGGER_TYPES) + +/** + * Parser for the `triggers` param, restricted to known core trigger types. + * Surfaced as `TriggerType[]` to match the consumer contract — unknown tokens + * are dropped (mirrors the prior `parseTriggerArrayFromURL` behavior). + */ +export const parseAsTriggers = createParser({ + parse(value) { + const triggers = value.split(',').filter((t): t is TriggerType => CORE_TRIGGER_SET.has(t)) + return triggers + }, + serialize(value) { + return value.join(',') + }, + eq(a, b) { + return a.length === b.length && a.every((v, i) => v === b[i]) + }, +}).withDefault([]) + +/** + * The nuqs parser map for every URL-synced logs filter. `clearOnDefault` keeps + * the URL clean (params drop out when they hold their default value) and + * `history: 'replace'` matches the prior `history.replaceState` behavior so + * filter changes don't pollute the browser back stack. + */ +export const logFilterParsers = { + timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE), + startDate: parseAsString, + endDate: parseAsString, + level: parseAsLogLevel.withDefault('all'), + workflowIds: parseAsArrayOf(parseAsString).withDefault([]), + folderIds: parseAsArrayOf(parseAsString).withDefault([]), + triggers: parseAsTriggers, + search: parseAsString.withDefault(''), +} as const + +/** Shared nuqs options for the logs filters: clean URLs, no back-stack churn. */ +export const logFilterUrlKeys = { + history: 'replace', + clearOnDefault: true, +} 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 + * shareable — so it carries no `clearOnDefault`/`history` options here. + */ +export const executionIdParam = { + key: 'executionId', + parser: parseAsString, +} as const + +const LOG_DETAILS_TABS = ['overview', 'trace'] as const + +/** + * Active tab of the log-details sidebar (`overview` / `trace`). Deep-linkable so + * a shared link can land on the trace view; `replace` keeps it off the back + * stack and `clearOnDefault` drops it from the URL when on the default tab. + */ +export const logDetailsTabParam = { + key: 'tab', + parser: parseAsStringLiteral(LOG_DETAILS_TABS).withDefault('overview'), +} as const + +/** Tab change is view-state, not a destination: replace, clean URL on default. */ +export const logDetailsTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts index fe1030220a..36a6b2dc3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { isSameDay } from 'date-fns' +import { useQueryStates } from 'nuqs' import { zonedClockDate } from '@/lib/core/utils/timezone' +import { + calendarParsers, + calendarUrlKeys, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params' import { advanceAnchor, type CalendarScope, @@ -40,43 +45,74 @@ export interface UseCalendarReturn { } /** - * Owns the calendar's ephemeral view state (scope, anchor, selected slot, and - * create-modal open state). Pure UI state — `useState`, not a store. Opens on - * the `week` scope. "Now" (the today highlight, the anchor's initial day) is - * resolved in `timezone` — the viewer's effective zone — so the calendar's date - * frame matches the zone tasks are scheduled in. `today` is polled so the today - * highlight and current-time column survive midnight without a remount; the poll - * only re-renders when the day actually changes (the interval is resilient to - * device sleep, unlike a one-shot timeout aimed at midnight). + * Owns the calendar's view state. `scope` and `anchor` live in the URL (nuqs) so + * the current view is shareable and survives reload / back-forward; the create + * modal and selected slot stay local (ephemeral UI). Opens on the `week` scope. + * "Now" (the today highlight, the default anchor) is resolved in `timezone` — the + * viewer's effective zone — so the calendar's date frame matches the zone tasks + * are scheduled in. The `anchor` param is date-only and nullable: a clean URL + * means "today", derived per-timezone here, so navigating to today clears the + * param. `today` is polled so the today highlight and current-time column survive + * midnight without a remount; the poll only re-renders when the day actually + * changes (the interval is resilient to device sleep, unlike a one-shot timeout + * aimed at midnight). */ export function useCalendar(timezone: string): UseCalendarReturn { const timezoneRef = useRef(timezone) const [today, setToday] = useState(() => zonedClockDate(new Date(), timezone)) - const [scope, setScope] = useState('week') - const [anchor, setAnchor] = useState(() => zonedClockDate(new Date(), timezone)) + const [{ scope, anchor: anchorParam }, setCalendarState] = useQueryStates( + calendarParsers, + calendarUrlKeys + ) const [selectedSlot, setSelectedSlot] = useState(null) const [isCreateOpen, setIsCreateOpen] = useState(false) const todayRef = useRef(today) + /** A clean URL (no `anchor` param) means "today", resolved in the effective zone. */ + const anchor = anchorParam ?? today + const anchorRef = useRef(anchor) + useEffect(() => { todayRef.current = today }, [today]) + useEffect(() => { + anchorRef.current = anchor + }, [anchor]) + + const setScope = useCallback( + (next: CalendarScope) => { + void setCalendarState({ scope: next }) + }, + [setCalendarState] + ) /** - * Re-sync to the effective zone's current day when `timezone` actually - * changes — e.g. when `useTimezone()` resolves from the browser fallback to - * the saved account zone after mount. The focused day follows only while it is - * still on "today", so an in-progress navigation is preserved. Owning - * `timezoneRef` here (instead of a separate sync effect) keeps the guard - * honest: the ref still reflects the previous zone when this runs. + * Set the focused day. Writing `today` (the default anchor) as `null` keeps the + * URL clean and preserves the "clean URL = today" invariant. + */ + const setAnchorDate = useCallback( + (date: Date) => { + void setCalendarState({ anchor: isSameDay(date, todayRef.current) ? null : date }) + }, + [setCalendarState] + ) + + /** + * Re-sync `today` to the effective zone's current day when `timezone` actually + * changes — e.g. when `useTimezone()` resolves from the browser fallback to the + * saved account zone after mount. When the URL holds an explicit anchor that + * was on the previous "today", drop it so the view follows to the new today; + * an in-progress navigation (anchor on another day) is preserved. */ useEffect(() => { if (timezoneRef.current === timezone) return timezoneRef.current = timezone const now = zonedClockDate(new Date(), timezone) - setAnchor((current) => (isSameDay(current, todayRef.current) ? now : current)) + if (anchorRef.current && isSameDay(anchorRef.current, todayRef.current)) { + void setCalendarState({ anchor: null }) + } setToday(now) - }, [timezone]) + }, [timezone, setCalendarState]) useEffect(() => { const id = setInterval(() => { @@ -86,15 +122,29 @@ export function useCalendar(timezone: string): UseCalendarReturn { return () => clearInterval(id) }, []) - const next = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, 1)), [scope]) - const prev = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, -1)), [scope]) - const goToday = useCallback(() => setAnchor(zonedClockDate(new Date(), timezoneRef.current)), []) - const goToDate = useCallback((date: Date) => setAnchor(date), []) + const next = useCallback( + () => setAnchorDate(advanceAnchor(anchorRef.current, scope, 1)), + [scope, setAnchorDate] + ) + const prev = useCallback( + () => setAnchorDate(advanceAnchor(anchorRef.current, scope, -1)), + [scope, setAnchorDate] + ) + const goToday = useCallback( + () => setAnchorDate(zonedClockDate(new Date(), timezoneRef.current)), + [setAnchorDate] + ) + const goToDate = useCallback((date: Date) => setAnchorDate(date), [setAnchorDate]) - const openDay = useCallback((date: Date) => { - setAnchor(date) - setScope('day') - }, []) + const openDay = useCallback( + (date: Date) => { + void setCalendarState({ + anchor: isSameDay(date, todayRef.current) ? null : date, + scope: 'day', + }) + }, + [setCalendarState] + ) const selectSlot = useCallback((date: Date, time?: string) => { setSelectedSlot({ date, time }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx index 1f0fadd8bf..38da2e2294 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx @@ -1,8 +1,22 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' +import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading' import { ScheduledTasks } from './scheduled-tasks' export const metadata: Metadata = { title: 'Scheduled Tasks', } -export default ScheduledTasks +/** + * Scheduled-tasks page entry. `ScheduledTasks` reads the calendar's `scope` / + * `anchor` 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. + */ +export default function ScheduledTasksPage() { + return ( + }> + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts new file mode 100644 index 0000000000..ab61104748 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts @@ -0,0 +1,61 @@ +import { createParser, parseAsStringLiteral } from 'nuqs/server' + +const CALENDAR_SCOPES = ['day', 'week', 'month'] as const + +/** Default calendar granularity; matches the prior `useState` initial scope. */ +export const DEFAULT_CALENDAR_SCOPE = 'week' + +const pad2 = (n: number) => String(n).padStart(2, '0') + +/** + * Local-time date-only parser (`yyyy-MM-dd`). + * + * Unlike nuqs's built-in `parseAsIsoDate` — which serializes via `toISOString()` + * and parses to **UTC** midnight — this reads and writes the date using the + * browser's **local** calendar fields. The calendar's `anchor` is a local-time + * `Date` (`zonedClockDate`) and all the grid math (`date-fns`) is local, so a + * UTC-based parser shifts the day by ±1 in any non-UTC timezone on + * reload/deep-link/back-forward. This local parser round-trips losslessly against + * that local-time math. + */ +const parseAsLocalDate = createParser({ + parse(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return null + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + return Number.isNaN(date.getTime()) ? null : date + }, + serialize(value) { + return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}` + }, + eq(a, b) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) + }, +}) + +/** + * Co-located, typed URL query-param definitions for the scheduled-tasks calendar. + * + * - `scope` is the calendar granularity (`day` / `week` / `month`). + * - `anchor` is the focused day, stored date-only (`yyyy-MM-dd`) via the + * local-time {@link parseAsLocalDate} so it matches the calendar's local-time + * date math (no timezone day-shift). It is intentionally **nullable** (no + * `.withDefault`): the default anchor is "today", which is dynamic and resolved + * per-timezone in the hook (`anchor = param ?? zonedClockDate(now, tz)`). A + * clean URL therefore means "today", and navigating back to today clears the + * param. + */ +export const calendarParsers = { + scope: parseAsStringLiteral(CALENDAR_SCOPES).withDefault(DEFAULT_CALENDAR_SCOPE), + anchor: parseAsLocalDate, +} as const + +/** Calendar view-state: clean URLs, no back-stack churn. */ +export const calendarUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts new file mode 100644 index 0000000000..907d11c765 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -0,0 +1,20 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the settings section pages. + * The client hook consumes this typed param definition as the single source of + * truth. + * + * `mcpServerId` deep-links the MCP settings tab to a specific server so the row + * can be focused/opened from a shared link. + */ +export const mcpServerIdParam = { + key: 'mcpServerId', + parser: parseAsString, +} as const + +/** Opening a server is a destination → push to history; clear on close. */ +export const mcpServerIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 073907ab5f..b194b2bf35 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -2,7 +2,6 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { captureEvent } from '@/lib/posthog/client' @@ -102,8 +101,6 @@ interface SettingsPageProps { } export function SettingsPage({ section }: SettingsPageProps) { - const searchParams = useSearchParams() - const mcpServerId = searchParams.get('mcpServerId') const { data: session, isPending: sessionLoading } = useSession() const posthog = usePostHog() @@ -144,7 +141,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'whitelabeling' && } {effectiveSection === 'byok' && } {effectiveSection === 'copilot' && } - {effectiveSection === 'mcp' && } + {effectiveSection === 'mcp' && } {effectiveSection === 'custom-tools' && } {effectiveSection === 'workflow-mcp-servers' && } {effectiveSection === 'inbox' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index d7e74848d2..62393f2d47 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -1,12 +1,17 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { Badge, Button, ChipInput, ChipSelect, Label, Search, Switch } from '@/components/emcn' import type { MothershipEnvironment } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' +import { + adminParsers, + adminUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/admin/search-params' import { useAdminUsers, useBanUser, @@ -41,9 +46,13 @@ export function Admin() { const impersonateUser = useImpersonateUser() const [workflowId, setWorkflowId] = useState('') - const [usersOffset, setUsersOffset] = useState(0) - const [searchInput, setSearchInput] = useState('') - const [searchQuery, setSearchQuery] = useState('') + + const [{ q: searchQuery, offset: usersOffset }, setAdminParams] = useQueryStates( + adminParsers, + adminUrlKeys + ) + + const [searchInput, setSearchInput] = useState(searchQuery) const [banUserId, setBanUserId] = useState(null) const [banReason, setBanReason] = useState('') const [impersonatingUserId, setImpersonatingUserId] = useState(null) @@ -56,10 +65,17 @@ export function Admin() { } = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery) const handleSearch = () => { - setUsersOffset(0) - setSearchQuery(searchInput.trim()) + const trimmed = searchInput.trim() + setAdminParams({ q: trimmed.length > 0 ? trimmed : null, offset: null }) } + const lastSyncedSearchRef = useRef(searchQuery) + useEffect(() => { + if (searchQuery === lastSyncedSearchRef.current) return + lastSyncedSearchRef.current = searchQuery + setSearchInput((current) => (current === searchQuery ? current : searchQuery)) + }, [searchQuery]) + const totalPages = useMemo( () => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE), [usersData?.total] @@ -410,7 +426,11 @@ export function Admin() {