feat(url-state): adopt nuqs for type-safe URL query-param state (#5163)

* feat(url-state): introduce nuqs for type-safe query-param state

Add nuqs and migrate ad-hoc URL query-param handling to typed parsers.

- Wrap the provider tree in `NuqsAdapter` (app/layout.tsx).
- Co-locate typed param modules:
  - logs/search-params.ts — timeRange/level/workflowIds/folderIds/triggers/
    search parsers (history: 'replace', clearOnDefault) preserving the exact
    prior wire encoding (kebab time-range tokens, comma-joined arrays).
  - integrations/[block]/search-params.ts — ephemeral `connect` literal param.
- Replace the logs filter store's hand-rolled URL sync (initializeFromURL /
  syncWithURL / popstate) with a URL-backed `useLogFilters` hook over
  useQueryStates; the zustand store now holds only the non-URL viewMode toggle.
- Migrate logs.tsx (executionId + search), logs-toolbar, dashboard, and the
  integration detail `connect` deep-link (read-then-strip) to nuqs.

URL keys, defaults, and history semantics are unchanged.

* feat(url-state): migrate deferred sites to nuqs + add url-state rule

Migrate the deferred query-param sites to typed nuqs parsers, each with a
co-located search-params.ts single source of truth:

- settings/[section]: mcpServerId deep-link
- files: folderId (history: push) + new compose flag
- knowledge/[id]: addConnector read-then-strip deep-link
- knowledge/[id]/[documentId]: page (int, default 1) + chunk deep-link

Workflow editor intentionally left store-backed (socket-synced / high-frequency
/ persisted-preference view-state); documented in the rule's carve-out.

Add .claude/rules/sim-url-state.md (decision framework, conventions, server
cache + debounced-input patterns, editor carve-out); cross-link from CLAUDE.md
and sim-queries.md.

* feat(url-state): migrate remaining view-state to nuqs + harness updates

Make the URL the single source of truth for shareable view-state across the
remaining sweep-confirmed sites:

- settings/mcp: replace initialServerId prop + effect-sync with a direct
  useQueryState (mcpServerId, history: push); stop prop-drilling from settings
- integrations: selectedCategory + debounced search; add Suspense boundary
- tables: debounced search + sort/dir + row-count/owner filters (activeTable
  stays route state — selecting a table navigates to tables/[tableId]); wire
  the existing loading.tsx as the Suspense fallback
- knowledge/[id]: pagination page param
- settings/recently-deleted: tab + sort/dir + debounced search
- settings/admin: committed search (q) + pagination offset
- settings/mothership: tab + environment
- skills: editingSkill object -> skillId deep-link (derive from useSkills); add
  Suspense boundary
- files: shareFileId deep-link added to files/search-params
- landing integrations + models directories: debounced search + category/
  provider filter; add Suspense boundaries

Harness: add a When-to-use decision table, the sort (sort+dir) convention, the
selected-entity deep-link pattern, and nuqs doc links to sim-url-state.md; add
the /you-might-not-need-url-state command and wire it into /cleanup.

* fix(nuqs): revert landing-page param migrations and tighten workspace URL-state

- Revert integrations/models landing pages to static SEO HTML (drop nuqs migration + their search-params files)
- MCP settings: refresh tools only for an initial deep-linked server id, not on subsequent user selections
- Add a Suspense boundary with real chrome around the nuqs-using integration detail page
- Trim inaccurate "server component reads these params" TSDoc from search-params files (createSearchParamsCache is unused)
- Export mcpServerIdUrlKeys from the settings search-params file instead of inlining the options in mcp.tsx
- Convert two new relative imports (logs use-log-filters, tables loading) to absolute
- Wrap setSelectedCategory in useCallback; clear active skillId edit param when opening the create-skill form

* fix(logs): add logs-page Suspense boundary and co-locate nuqs params

- Wrap <Logs/> in <Suspense fallback={<LogsLoading/>}> so the nuqs reads
  (useLogFilters, executionId) have a boundary ancestor like sibling pages.
- Co-locate the executionId param in logs/search-params.ts (read-only,
  intentionally not stripped) and consume it in logs.tsx.
- Migrate log-details activeTab to a deep-linkable nuqs tab param (single
  LogDetails instance; preview path uses ExecutionSnapshot, not LogDetails).
- Align cleanup.md description pass order with the numbered steps.
- Replace mothership.tsx local Tab type with exported MothershipTab.

* fix(url-state): honor deep-linked log-details tab on first mount

* improvement(nuqs): adopt limitUrlUpdates debounce + add eq to array parser

Replace the hand-rolled debounced-search pattern (local useState mirror +
useDebounce + URL write-back effect + ref-guarded reconcile effect) with
nuqs's built-in limitUrlUpdates: debounce() across logs, integrations,
tables, and recently-deleted. The input is now controlled directly by the
instant nuqs value; only the URL write is debounced. Query keys / expensive
filters still derive a debounced value off the instant value; cheap in-memory
filters read it directly. admin (commit-on-submit) intentionally left alone.

Add an eq to parseAsTriggers (TriggerType[]) so clearOnDefault can detect the
empty-array default and strip it from the URL, per nuqs createParser docs.

Update .claude/rules/sim-url-state.md to prescribe the debounce pattern and the
createParser eq requirement for array/object/Date values.

* feat(nuqs): migrate table-detail sort, KB document filters, and calendar view to URL state

- Table detail: sort+dir to nuqs; Filter stays in useState (recursive/nested, too large for URL)
- Knowledge base: search (debounced), enabled filter, sort+dir to nuqs; tagFilterEntries stays in useState (rich rule objects)
- Scheduled-tasks calendar: scope + date-only anchor (parseAsIsoDate, nullable, derive-today) to nuqs
- Add Suspense boundaries to table-detail and scheduled-tasks pages
- Document parseAsIsoDate / nullable-dynamic-default pattern in sim-url-state.md

* fix(nuqs): resolve 7 PR review findings on URL query-param state

- logs: clear the log-details `tab` param when the sidebar closes so a
  lingering `?tab=trace` no longer carries into the next opened log;
  deep-linked tabs still open on first mount.
- logs dashboard: drive the in-memory workflow filtering off the same
  debounced search value the stats query uses (passed as a prop) so the
  chart and list stay consistent while typing.
- knowledge document: make the URL `chunk` param the single source of
  truth for the open chunk (back/forward, deep links, and external
  navigation now drive the editor) instead of a one-time useState seed.
- logs: drop the redundant `setUrlSearchQuery('')` after `resetFilters()`
  (resetFilters already clears `search`).
- files: use a per-call `{ history: 'replace' }` override for the
  `shareFileId` share-modal open/close writes so toggling the modal does
  not pollute the back/forward stack; folder navigation keeps `push`.
- tables + recently-deleted: trim search input before deriving the URL
  value so whitespace-only input no longer writes `?search=%20`.

* fix(url-state): trim whitespace-only search in integrations filter

* fix(url-state): clear log tab on all close paths + trim KB search

* fix(scheduled-tasks): use local-time date parser for calendar anchor (avoid UTC day-shift)

* feat(home): migrate ?resource deep-link to nuqs (URL as source of truth)

Replace the banned window.history.replaceState effect on the home/Chat
surface with a nuqs useQueryState('resource') binding. The URL is now the
single source of truth for the selected resource.

- Add co-located home/search-params.ts (resource param, history: replace)
- useChat accepts a controlled activeResourceState binding; home passes the
  nuqs-backed tuple. The workflow editor copilot keeps internal useState so
  its resource selection stays out of the URL (editor carve-out)
- Preserve the old effect's url.hash='' fragment strip in the binding setter
  (fragment-only rewrite, not a param mutation)
- Drop initialResourceId SSR prop from both page entries (nuqs reads the URL
  on mount; no dual source) and wrap Home in Suspense for useSearchParams

* docs(home): note nuqs deferred-flush ordering in resource hash-strip

* docs(url-state): convert inline comments to TSDoc
This commit is contained in:
Waleed
2026-06-21 20:51:03 -07:00
committed by GitHub
parent 9a2e06e795
commit f5d42ce452
57 changed files with 2080 additions and 773 deletions
+3 -2
View File
@@ -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.
@@ -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 `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.
## 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.
+2
View File
@@ -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:
+215
View File
@@ -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<Record<string, string | string[] | undefined>> }) {
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 `<Suspense>` 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
+6
View File
@@ -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.
+14 -11
View File
@@ -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=
</noscript>
)}
<HydrationErrorHandler />
<PostHogProvider>
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>
</PostHogProvider>
<NuqsAdapter>
<PostHogProvider>
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>
</PostHogProvider>
</NuqsAdapter>
</body>
</html>
)
@@ -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 (
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
initialResourceId={resource ?? null}
/>
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
/>
</Suspense>
)
}
@@ -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<string | null>(null)
const listRename = useInlineRename({
onSave: (rowId, name) => {
@@ -303,7 +303,9 @@ export function Files() {
const shareModal = shareFile ? (
<ShareModal
open
onOpenChange={(open) => !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,
@@ -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.
*/
@@ -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<boolean>({
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
@@ -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 <div className='h-full bg-[var(--bg)]' />
}
@@ -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<Dispatch<SetStateAction<string | null>>>(
(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<SetStateAction<string | null>>]>(
() => [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) {
@@ -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<SetStateAction<string | null>>]
/** 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<string | undefined>(initialChatId)
const [queuedHandoffRecoveryEpoch, setQueuedHandoffRecoveryEpoch] = useState(0)
const [resources, setResources] = useState<MothershipResource[]>([])
const [activeResourceId, setActiveResourceId] = useState<string | null>(
const internalActiveResourceState = useState<string | null>(
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<GenericResourceData | null>(null)
const onResourceEventRef = useRef(options?.onResourceEvent)
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
@@ -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 (
<Home
userName={session?.user?.name}
userId={session?.user?.id}
initialResourceId={resource ?? null}
/>
<Suspense fallback={<HomeFallback />}>
<Home userName={session?.user?.name} userId={session?.user?.id} />
</Suspense>
)
}
@@ -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
@@ -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
? [
@@ -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 <IntegrationBlockDetail integration={integration} workspaceId={workspaceId} />
return (
<Suspense
fallback={
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
</div>
</div>
}
>
<IntegrationBlockDetail integration={integration} workspaceId={workspaceId} />
</Suspense>
)
}
@@ -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
@@ -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<string>(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 && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
{searchTerm.trim()
? `No integrations found matching “${searchTerm}”`
{urlSearchTerm.trim()
? `No integrations found matching “${urlSearchTerm}”`
: 'No integrations in this category'}
</div>
)}
@@ -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 (
<Suspense
fallback={
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
</div>
}
>
<Integrations />
</Suspense>
)
}
@@ -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
@@ -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<Set<string>>(() => new Set())
// Inline editor state
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(() =>
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<BreadcrumbItem[]>(
() =>
@@ -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) => {
@@ -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
@@ -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<Set<string>>(() => new Set())
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
@@ -278,32 +283,64 @@ export function KnowledgeBase({
const [documentToDelete, setDocumentToDelete] = useState<string | null>(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<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(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}
/>
)}
@@ -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
@@ -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<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
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)
@@ -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<LogDetailsTab>('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<HTMLDivElement>(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'
@@ -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)
@@ -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,
]
)
}
@@ -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<string | null>(() =>
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<string | null>(() => 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<string | undefined>(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: (
<LogsFilterPanel searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} />
<LogsFilterPanel
searchQuery={urlSearchQuery}
onSearchQueryChange={setUrlSearchQuery}
/>
),
}}
filterTags={filterTags}
@@ -1121,6 +1115,7 @@ export default function Logs() {
stats={dashboardStatsQuery.data}
isLoading={dashboardStatsQuery.isLoading}
error={dashboardStatsQuery.error}
searchQuery={debouncedSearchQuery}
/>
</div>
{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)
@@ -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 (
<Suspense fallback={<LogsLoading />}>
<Logs />
</Suspense>
)
}
@@ -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<TimeRange, string> = {
'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<string, TimeRange> = Object.fromEntries(
Object.entries(TIME_RANGE_TO_TOKEN).map(([label, token]) => [token, label as TimeRange])
) as Record<string, TimeRange>
/**
* 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<TimeRange>({
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<LogLevel>({
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<string>(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<TriggerType[]>({
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
@@ -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<Date>(() => zonedClockDate(new Date(), timezone))
const [scope, setScope] = useState<CalendarScope>('week')
const [anchor, setAnchor] = useState<Date>(() => zonedClockDate(new Date(), timezone))
const [{ scope, anchor: anchorParam }, setCalendarState] = useQueryStates(
calendarParsers,
calendarUrlKeys
)
const [selectedSlot, setSelectedSlot] = useState<CalendarSlot | null>(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 })
@@ -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 (
<Suspense fallback={<ScheduledTasksLoading />}>
<ScheduledTasks />
</Suspense>
)
}
@@ -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<Date>({
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
@@ -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
@@ -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' && <WhitelabelingSettings />}
{effectiveSection === 'byok' && <BYOK />}
{effectiveSection === 'copilot' && <Copilot />}
{effectiveSection === 'mcp' && <MCP initialServerId={mcpServerId} />}
{effectiveSection === 'mcp' && <MCP />}
{effectiveSection === 'custom-tools' && <CustomTools />}
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
@@ -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<string | null>(null)
const [banReason, setBanReason] = useState('')
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(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() {
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => setUsersOffset((prev) => prev - PAGE_SIZE)}
onClick={() =>
setAdminParams((prev) => ({
offset: Math.max(0, prev.offset - PAGE_SIZE),
}))
}
disabled={usersOffset === 0 || usersLoading}
>
Previous
@@ -418,7 +438,9 @@ export function Admin() {
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => setUsersOffset((prev) => prev + PAGE_SIZE)}
onClick={() =>
setAdminParams((prev) => ({ offset: prev.offset + PAGE_SIZE }))
}
disabled={
usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading
}
@@ -0,0 +1,21 @@
import { parseAsInteger, parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definitions for the Admin user-management
* view.
*
* - `q` is the committed user search query. The visible input keeps its own
* local state; only the submitted query lands in the URL (search here is an
* explicit submit, not a per-keystroke debounce).
* - `offset` is the 0-based pagination offset into the admin user list.
*/
export const adminParsers = {
q: parseAsString.withDefault(''),
offset: parseAsInteger.withDefault(0),
} as const
/** Search/pagination view-state: clean URLs, no back-stack churn. */
export const adminUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -1,10 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import {
Badge,
Button,
@@ -25,6 +26,10 @@ import {
type McpToolIssue,
} from '@/lib/mcp/tool-validation'
import type { McpTransport } from '@/lib/mcp/types'
import {
mcpServerIdParam,
mcpServerIdUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
import {
@@ -143,11 +148,7 @@ function buildEditInitialData(server: McpServer) {
}
}
interface MCPProps {
initialServerId?: string | null
}
export function MCP({ initialServerId }: MCPProps) {
export function MCP() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -184,16 +185,20 @@ export function MCP({ initialServerId }: MCPProps) {
const [serverToDeleteId, setServerToDeleteId] = useState<string | null>(null)
const showDeleteDialog = serverToDeleteId !== null
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId ?? null)
const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, {
...mcpServerIdParam.parser,
...mcpServerIdUrlKeys,
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialServerId and workspaceId
// are stable for the component's lifetime (route changes remount the settings page).
const initialServerIdRef = useRef(selectedServerId)
const didDeepLinkRefreshRef = useRef(false)
useEffect(() => {
if (initialServerId) {
forceRefreshTools(workspaceId)
refetchStoredTools()
}
}, [])
if (didDeepLinkRefreshRef.current) return
if (!initialServerIdRef.current) return
didDeepLinkRefreshRef.current = true
forceRefreshTools(workspaceId)
refetchStoredTools()
}, [workspaceId, forceRefreshTools, refetchStoredTools])
const [expandedTools, setExpandedTools] = useState<Set<string>>(() => new Set())
@@ -2,6 +2,7 @@
import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
@@ -9,6 +10,11 @@ import {
BYOKKeyManager,
type BYOKManagerProvider,
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager'
import {
type MothershipTab,
mothershipParsers,
mothershipUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/mothership/search-params'
import {
type MothershipByokKey,
type MothershipEnv,
@@ -38,9 +44,7 @@ const ENTERPRISE_BYOK_PROVIDERS: BYOKManagerProvider[] = [
},
]
type Tab = 'overview' | 'licenses' | 'byok'
const TABS: { id: Tab; label: string }[] = [
const TABS: { id: MothershipTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'licenses', label: 'Licenses' },
{ id: 'byok', label: 'BYOK' },
@@ -85,8 +89,10 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
}
export function Mothership() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [environment, setEnvironment] = useState<MothershipEnv>('dev')
const [{ tab: activeTab, env: environment }, setMothershipParams] = useQueryStates(
mothershipParsers,
mothershipUrlKeys
)
const defaults = useMemo(() => defaultTimeRange(), [])
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
@@ -103,7 +109,7 @@ export function Mothership() {
<button
key={opt.id}
type='button'
onClick={() => setEnvironment(opt.id)}
onClick={() => setMothershipParams({ env: opt.id })}
className={cn(
'rounded-md px-3 py-1 font-medium text-sm transition-colors',
environment === opt.id
@@ -123,7 +129,7 @@ export function Mothership() {
<button
key={tab.id}
type='button'
onClick={() => setActiveTab(tab.id)}
onClick={() => setMothershipParams({ tab: tab.id })}
className={cn(
'relative px-3 py-2 font-medium text-sm transition-colors',
activeTab === tab.id
@@ -0,0 +1,35 @@
import { parseAsStringLiteral } from 'nuqs/server'
/** Mothership admin tabs. */
export const MOTHERSHIP_TABS = ['overview', 'licenses', 'byok'] as const
export type MothershipTab = (typeof MOTHERSHIP_TABS)[number]
/**
* Mothership environments. Mirrors the `MothershipEnv` union (including the
* `default` member) so the literal parser accepts every value the selector and
* query hooks emit, even though only dev/staging/prod are surfaced as buttons.
*/
export const MOTHERSHIP_ENVIRONMENTS = ['default', 'dev', 'staging', 'prod'] as const
export type MothershipEnvironmentParam = (typeof MOTHERSHIP_ENVIRONMENTS)[number]
/**
* Co-located, typed URL query-param definitions for the Mothership admin view.
*
* - `tab` is the active section tab.
* - `env` is the selected environment.
*
* The time-range inputs are free-form datetimes (not a finite enum) and
* intentionally stay in local component state.
*/
export const mothershipParsers = {
tab: parseAsStringLiteral(MOTHERSHIP_TABS).withDefault('overview'),
env: parseAsStringLiteral(MOTHERSHIP_ENVIRONMENTS).withDefault('dev'),
} as const
/** Tab/environment view-state: clean URLs, no back-stack churn. */
export const mothershipUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -1,14 +1,24 @@
'use client'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import { Button, ChipInput, ChipModalTabs } from '@/components/emcn'
import { Folder, Search, Workflow } from '@/components/emcn/icons'
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import {
DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
RECENTLY_DELETED_SORT_COLUMNS,
type RecentlyDeletedSortColumn,
type RecentlyDeletedTab,
recentlyDeletedParsers,
recentlyDeletedUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
@@ -61,6 +71,9 @@ interface SortConfig {
const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' }
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const SORT_OPTIONS: ColumnOption[] = [
{ id: 'deleted', label: 'Deleted' },
{ id: 'name', label: 'Name' },
@@ -139,9 +152,37 @@ export function RecentlyDeleted() {
const params = useParams()
const router = useRouter()
const workspaceId = params?.workspaceId as string
const [activeTab, setActiveTab] = useState<ResourceType>('all')
const [searchTerm, setSearchTerm] = useState('')
const [activeSort, setActiveSort] = useState<SortConfig | null>(null)
const [
{ tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm },
setRecentlyDeletedFilters,
] = useQueryStates(recentlyDeletedParsers, recentlyDeletedUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over a small list, so
* it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setRecentlyDeletedFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setRecentlyDeletedFilters]
)
const activeSort = useMemo<SortConfig | null>(
() =>
sortColumn === DEFAULT_RECENTLY_DELETED_SORT_COLUMN &&
sortDirection === DEFAULT_RECENTLY_DELETED_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, RestoredResourceEntry>>(new Map())
@@ -253,8 +294,8 @@ export function RecentlyDeleted() {
const filtered = useMemo(() => {
let items = resources.filter((resource) => matchesActiveTab(resource, activeTab))
if (searchTerm.trim()) {
const normalized = searchTerm.toLowerCase()
if (urlSearchTerm.trim()) {
const normalized = urlSearchTerm.toLowerCase()
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
}
const col = (activeSort ?? DEFAULT_SORT).column
@@ -280,8 +321,8 @@ export function RecentlyDeleted() {
if (itemIds.has(id)) continue
if (!matchesActiveTab(entry.resource, activeTab)) continue
if (
searchTerm.trim() &&
!entry.resource.name.toLowerCase().includes(searchTerm.toLowerCase())
urlSearchTerm.trim() &&
!entry.resource.name.toLowerCase().includes(urlSearchTerm.toLowerCase())
) {
continue
}
@@ -289,9 +330,9 @@ export function RecentlyDeleted() {
}
return items
}, [resources, activeTab, searchTerm, activeSort, restoredItems])
}, [resources, activeTab, urlSearchTerm, activeSort, restoredItems])
const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0
const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0
function handleView(resource: DeletedResource) {
if (resource.type === 'folder') {
@@ -372,7 +413,7 @@ export function RecentlyDeleted() {
<ChipInput
icon={Search}
placeholder='Search deleted items...'
value={searchTerm}
value={urlSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='min-w-0 flex-1'
@@ -381,9 +422,17 @@ export function RecentlyDeleted() {
config={{
options: SORT_OPTIONS,
active: activeSort,
onSort: (column, direction) =>
setActiveSort({ column: column as SortColumn, direction }),
onClear: () => setActiveSort(null),
onSort: (column, direction) => {
const sort = (RECENTLY_DELETED_SORT_COLUMNS as readonly string[]).includes(column)
? (column as RecentlyDeletedSortColumn)
: DEFAULT_RECENTLY_DELETED_SORT_COLUMN
setRecentlyDeletedFilters({ sort, dir: direction })
},
onClear: () =>
setRecentlyDeletedFilters({
sort: DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
dir: DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
}),
}}
/>
</div>
@@ -391,7 +440,7 @@ export function RecentlyDeleted() {
<ChipModalTabs
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label }))}
value={activeTab}
onChange={(v) => setActiveTab(v as ResourceType)}
onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })}
/>
{error ? (
@@ -403,7 +452,7 @@ export function RecentlyDeleted() {
) : isLoading ? null : filtered.length === 0 ? (
showNoResults ? (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
{`No items found matching \u201c${searchTerm}\u201d`}
{`No items found matching \u201c${urlSearchTerm}\u201d`}
</div>
) : (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
@@ -0,0 +1,49 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
/** Selectable resource-type tabs in the Recently Deleted view. */
export const RECENTLY_DELETED_TABS = [
'all',
'workflow',
'folder',
'table',
'knowledge',
'file',
] as const
export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number]
/** Sortable columns for the deleted-items list. */
export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const
export type RecentlyDeletedSortColumn = (typeof RECENTLY_DELETED_SORT_COLUMNS)[number]
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort: most-recently-deleted first. */
export const DEFAULT_RECENTLY_DELETED_SORT_COLUMN: RecentlyDeletedSortColumn = 'deleted'
export const DEFAULT_RECENTLY_DELETED_SORT_DIRECTION = 'desc'
/**
* Co-located, typed URL query-param definitions for the Recently Deleted
* settings view.
*
* - `tab` is the active resource-type filter.
* - `sort` / `dir` follow the shared sort convention.
* - `search` is the name filter. The input is controlled directly by the nuqs
* value; only its URL write is debounced via `limitUrlUpdates` (`debounce`) on
* the setter — never written on every keystroke.
*/
export const recentlyDeletedParsers = {
tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'),
sort: parseAsStringLiteral(RECENTLY_DELETED_SORT_COLUMNS).withDefault(
DEFAULT_RECENTLY_DELETED_SORT_COLUMN
),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_RECENTLY_DELETED_SORT_DIRECTION),
search: parseAsString.withDefault(''),
} as const
/** Tab/filter/sort view-state: clean URLs, no back-stack churn. */
export const recentlyDeletedUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -1,8 +1,30 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { Skills } from '@/app/workspace/[workspaceId]/skills/skills'
export const metadata: Metadata = {
title: 'Skills',
}
export default Skills
/**
* Skills page entry. `Skills` 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 SkillsPage({ params }: { params: Promise<{ workspaceId: string }> }) {
const { workspaceId } = await params
return (
<Suspense
fallback={
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='skills' workspaceId={workspaceId} />
</div>
}
>
<Skills />
</Suspense>
)
}
@@ -0,0 +1,21 @@
import { parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definition for the Skills gallery.
*
* `skillId` deep-links the skill edit modal to a specific skill. The modal
* opens when a valid id (one that resolves to a loaded skill) is present;
* closing it clears the param. Opening a skill is a destination, so it lands in
* the browser history (`history: 'push'`). The "create new skill" flow has no id
* and stays in local component state.
*/
export const skillIdParam = {
key: 'skillId',
parser: parseAsString,
} as const
/** Opening a skill is a destination → push to history; clear on close. */
export const skillIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
@@ -5,12 +5,13 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { skillIdParam, skillIdUrlKeys } from '@/app/workspace/[workspaceId]/skills/search-params'
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
const logger = createLogger('SkillsSettings')
@@ -67,11 +68,17 @@ export function Skills() {
const deleteSkillMutation = useDeleteSkill()
const [searchTerm, setSearchTerm] = useState('')
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, {
...skillIdParam.parser,
...skillIdUrlKeys,
})
const [showAddForm, setShowAddForm] = useState(false)
const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
/** Derive the skill being edited from the loaded list — never store the object in the URL. */
const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null
const filteredSkills = skills.filter((s) => {
if (!searchTerm.trim()) return true
const searchLower = searchTerm.toLowerCase()
@@ -109,18 +116,18 @@ export function Skills() {
const handleSkillSaved = () => {
setShowAddForm(false)
setEditingSkill(null)
setEditingSkillId(null)
}
const showNoResults = searchTerm.trim() && filteredSkills.length === 0
const handleOpenAddForm = () => {
setEditingSkillId(null)
setShowAddForm(true)
}
const addButton = (
<Chip
variant='primary'
onClick={() => setShowAddForm(true)}
disabled={isLoading}
leftIcon={Plus}
>
<Chip variant='primary' onClick={handleOpenAddForm} disabled={isLoading} leftIcon={Plus}>
Add to Sim
</Chip>
)
@@ -154,7 +161,7 @@ export function Skills() {
key={s.id}
name={s.name}
description={s.description}
onClick={() => setEditingSkill(s)}
onClick={() => setEditingSkillId(s.id)}
/>
))}
</SkillSection>
@@ -172,12 +179,12 @@ export function Skills() {
onOpenChange={(open) => {
if (!open) {
setShowAddForm(false)
setEditingSkill(null)
setEditingSkillId(null)
}
}}
onSave={handleSkillSaved}
onDelete={(skillId) => {
setEditingSkill(null)
setEditingSkillId(null)
handleDeleteClick(skillId)
}}
initialValues={editingSkill ?? undefined}
@@ -1,10 +1,21 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
import { Table } from './table'
export const metadata: Metadata = {
title: 'Table',
}
/**
* Table-detail page entry. `Table` 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.
*/
export default function TablePage() {
return <Table />
return (
<Suspense fallback={<TableLoading />}>
<Table />
</Suspense>
)
}
@@ -0,0 +1,31 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort direction applied when a sort column is selected. */
export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc'
/**
* Co-located, typed URL query-param definitions for the table-detail view.
*
* - `sort` is the active sort column. Columns are user-defined table columns
* (not a fixed set), so the column id is stored as a free-form string. A
* `null` value means "no active sort" — the table's natural row order — and
* clears from the URL.
* - `dir` is the sort direction, following the shared `sort`+`dir` convention.
*
* The in-grid `filter` is intentionally NOT represented here. `Filter` is a
* recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column
* operator objects); serializing it would put a large structured blob in the
* URL, which the URL-state doctrine forbids. It stays in local `useState`.
*/
export const tableDetailParsers = {
sort: parseAsString,
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_DETAIL_SORT_DIRECTION),
} as const
/** Sort view-state: clean URLs, no back-stack churn. */
export const tableDetailUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -3,12 +3,19 @@
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import { Chip, ChipConfirmModal, toast } from '@/components/emcn'
import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@/components/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import type {
ColumnDefinition,
Filter,
Sort,
TableRow as TableRowType,
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import {
@@ -53,6 +60,11 @@ import {
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
import { useTable, useTableEventStream } from './hooks'
import {
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
tableDetailParsers,
tableDetailUrlKeys,
} from './search-params'
import type { QueryOptions } from './types'
import { generateColumnName } from './utils'
@@ -161,9 +173,25 @@ export function Table({
selectionStats: { hasIncompleteOrFailed: false, hasCompleted: false, hasInFlight: false },
singleWorkflowCell: null,
})
const [queryOptions, setQueryOptions] = useState<QueryOptions>({ filter: null, sort: null })
const [filter, setFilter] = useState<Filter | null>(null)
const [filterOpen, setFilterOpen] = useState(false)
const [{ sort: sortColumn, dir: sortDirection }, setSortParams] = useQueryStates(
tableDetailParsers,
tableDetailUrlKeys
)
/** Resolved single-column sort, or `null` when no column is active. */
const sortQuery = useMemo<Sort | null>(
() => (sortColumn ? { [sortColumn]: sortDirection } : null),
[sortColumn, sortDirection]
)
const queryOptions = useMemo<QueryOptions>(
() => ({ filter, sort: sortQuery }),
[filter, sortQuery]
)
const userPermissions = useUserPermissionsContext()
const onOpenColumnConfig = useCallback((config: ColumnConfig) => {
@@ -471,26 +499,22 @@ export function Table({
[columns]
)
const sortConfig = useMemo<SortConfig>(() => {
let active: SortConfig['active'] = null
if (queryOptions.sort) {
const entries = Object.entries(queryOptions.sort)
if (entries.length > 0) {
const [column, direction] = entries[0]
active = { column, direction }
}
}
return {
const sortConfig = useMemo<SortConfig>(
() => ({
options: columnOptions,
active,
onSort: (column, direction) =>
setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })),
onClear: () => setQueryOptions((prev) => ({ ...prev, sort: null })),
}
}, [columnOptions, queryOptions.sort])
active: sortColumn ? { column: sortColumn, direction: sortDirection } : null,
onSort: (column, direction) => setSortParams({ sort: column, dir: direction }),
/**
* Clearing writes the default direction (stripped by clearOnDefault) and
* drops the column, leaving a clean URL with no active sort.
*/
onClear: () => setSortParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }),
}),
[columnOptions, sortColumn, sortDirection, setSortParams]
)
const handleFilterApply = (filter: Filter | null) => {
setQueryOptions((prev) => ({ ...prev, filter }))
const handleFilterApply = (next: Filter | null) => {
setFilter(next)
}
const breadcrumbs = useMemo(
@@ -1,8 +1,22 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading'
import { Tables } from './tables'
export const metadata: Metadata = {
title: 'Tables',
}
export default Tables
/**
* Tables page entry. `Tables` 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 TablesPage() {
return (
<Suspense fallback={<TablesLoading />}>
<Tables />
</Suspense>
)
}
@@ -0,0 +1,47 @@
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
/** Sortable table columns, matching the `Resource.Options` sort menu. */
export const TABLE_SORT_COLUMNS = [
'name',
'columns',
'rows',
'created',
'owner',
'updated',
] as const
export type TableSortColumn = (typeof TABLE_SORT_COLUMNS)[number]
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort: most-recently-updated first. */
export const DEFAULT_TABLE_SORT_COLUMN: TableSortColumn = 'updated'
export const DEFAULT_TABLE_SORT_DIRECTION = 'desc'
/**
* Co-located, typed URL query-param definitions for the Tables list.
*
* - `search` is the table name filter. The input is controlled directly by the
* nuqs value; only its URL write is debounced via `limitUrlUpdates`
* (`debounce`) on the setter — never written on every keystroke.
* - `sort` / `dir` follow the shared sort convention (two scalar params).
* - `rows` filters by row-count bucket; `owner` filters by creator id. Both are
* multi-select arrays.
*
* Selecting a table navigates to the `tables/[tableId]` route (via `router`),
* so the active table is route state, not query state, and is intentionally not
* represented here.
*/
export const tablesParsers = {
search: parseAsString.withDefault(''),
sort: parseAsStringLiteral(TABLE_SORT_COLUMNS).withDefault(DEFAULT_TABLE_SORT_COLUMN),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_SORT_DIRECTION),
rows: parseAsArrayOf(parseAsString).withDefault([]),
owner: parseAsArrayOf(parseAsString).withDefault([]),
} as const
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
export const tablesUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import type { ComboboxOption } from '@/components/emcn'
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
@@ -25,6 +26,14 @@ import {
TablesListContextMenu,
} from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import {
DEFAULT_TABLE_SORT_COLUMN,
DEFAULT_TABLE_SORT_DIRECTION,
TABLE_SORT_COLUMNS,
type TableSortColumn,
tablesParsers,
tablesUrlKeys,
} from '@/app/workspace/[workspaceId]/tables/search-params'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
cancelTableJob,
@@ -44,6 +53,9 @@ import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('Tables')
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
@@ -86,14 +98,57 @@ export function Tables() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 300)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [rowCountFilter, setRowCountFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [
{
search: urlSearchTerm,
sort: sortColumn,
dir: sortDirection,
rows: rowCountFilter,
owner: ownerFilter,
},
setTableFilters,
] = useQueryStates(tablesParsers, tablesUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. The in-memory filter below still reads a debounced value
* so it doesn't recompute on every keystroke.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setTableFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setTableFilters]
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, 300)
/**
* The resolved sort is exposed to the sort menu only when it differs from the
* default, mirroring the prior `null`-means-default semantics.
*/
const activeSort = useMemo(
() =>
sortColumn === DEFAULT_TABLE_SORT_COLUMN && sortDirection === DEFAULT_TABLE_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)
const setRowCountFilter = useCallback(
(next: string[]) => setTableFilters({ rows: next }),
[setTableFilters]
)
const setOwnerFilter = useCallback(
(next: string[]) => setTableFilters({ owner: next }),
[setTableFilters]
)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const uploading = uploadProgress.total > 0
const csvInputRef = useRef<HTMLInputElement>(null)
@@ -203,12 +258,12 @@ export function Tables() {
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchTerm,
value: urlSearchTerm,
onChange: setSearchTerm,
onClearAll: () => setSearchTerm(''),
placeholder: 'Search tables...',
}),
[searchTerm]
[urlSearchTerm, setSearchTerm]
)
const sortConfig: SortConfig = useMemo(
@@ -222,10 +277,19 @@ export function Tables() {
{ id: 'updated', label: 'Last Updated' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
onSort: (column, direction) => {
const sort = (TABLE_SORT_COLUMNS as readonly string[]).includes(column)
? (column as TableSortColumn)
: DEFAULT_TABLE_SORT_COLUMN
setTableFilters({ sort, dir: direction })
},
onClear: () =>
setTableFilters({
sort: DEFAULT_TABLE_SORT_COLUMN,
dir: DEFAULT_TABLE_SORT_DIRECTION,
}),
}),
[activeSort]
[activeSort, setTableFilters]
)
const rowCountDisplayLabel = useMemo(() => {
+1
View File
@@ -169,6 +169,7 @@
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
"nodemailer": "8.0.9",
"nuqs": "2.8.9",
"officeparser": "^5.2.0",
"openai": "^4.91.1",
"pdf-lib": "1.17.1",
+14 -311
View File
@@ -1,314 +1,17 @@
import { create } from 'zustand'
import {
CORE_TRIGGER_TYPES,
type FilterState,
type LogLevel,
type TimeRange,
type TriggerType,
} from '@/stores/logs/filters/types'
import { devtools } from 'zustand/middleware'
import type { LogViewState } from '@/stores/logs/filters/types'
const getSearchParams = () => {
if (typeof window === 'undefined') return new URLSearchParams()
return new URLSearchParams(window.location.search)
}
const updateURL = (params: URLSearchParams) => {
if (typeof window === 'undefined') return
const url = new URL(window.location.href)
url.search = params.toString()
window.history.replaceState({}, '', url)
}
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
const parseTimeRangeFromURL = (value: string | null): TimeRange => {
switch (value) {
case 'all-time':
return 'All time'
case 'past-30-minutes':
return 'Past 30 minutes'
case 'past-hour':
return 'Past hour'
case 'past-6-hours':
return 'Past 6 hours'
case 'past-12-hours':
return 'Past 12 hours'
case 'past-24-hours':
return 'Past 24 hours'
case 'past-3-days':
return 'Past 3 days'
case 'past-7-days':
return 'Past 7 days'
case 'past-14-days':
return 'Past 14 days'
case 'past-30-days':
return 'Past 30 days'
case 'custom':
return 'Custom range'
default:
return DEFAULT_TIME_RANGE
}
}
const parseLogLevelFromURL = (value: string | null): LogLevel => {
if (!value) return 'all'
const levels = value.split(',').filter(Boolean)
const validLevels = levels.filter(
(l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending'
/**
* Logs view store. Holds only non-URL view state (the logs/dashboard toggle).
* All filter state is URL-backed via `useLogFilters` (nuqs).
*/
export const useFilterStore = create<LogViewState>()(
devtools(
(set) => ({
viewMode: 'logs',
setViewMode: (viewMode) => set({ viewMode }),
}),
{ name: 'logs-view-store' }
)
if (validLevels.length === 0) return 'all'
if (validLevels.length === 1) return validLevels[0] as LogLevel
return validLevels.join(',') as LogLevel
}
const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => {
if (!value) return []
return value
.split(',')
.filter((t): t is TriggerType => (CORE_TRIGGER_TYPES as readonly string[]).includes(t))
}
const parseStringArrayFromURL = (value: string | null): string[] => {
if (!value) return []
return value.split(',').filter(Boolean)
}
const timeRangeToURL = (timeRange: TimeRange): string => {
switch (timeRange) {
case 'Past 30 minutes':
return 'past-30-minutes'
case 'Past hour':
return 'past-hour'
case 'Past 6 hours':
return 'past-6-hours'
case 'Past 12 hours':
return 'past-12-hours'
case 'Past 24 hours':
return 'past-24-hours'
case 'Past 3 days':
return 'past-3-days'
case 'Past 7 days':
return 'past-7-days'
case 'Past 14 days':
return 'past-14-days'
case 'Past 30 days':
return 'past-30-days'
case 'Custom range':
return 'custom'
default:
return 'all-time'
}
}
export const useFilterStore = create<FilterState>((set, get) => ({
workspaceId: '',
viewMode: 'logs',
timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
level: 'all',
workflowIds: [],
folderIds: [],
searchQuery: '',
triggers: [],
isInitializing: false,
setWorkspaceId: (workspaceId) => set({ workspaceId }),
setViewMode: (viewMode) => set({ viewMode }),
setTimeRange: (timeRange) => {
set({ timeRange })
if (!get().isInitializing) {
get().syncWithURL()
}
},
setDateRange: (start, end) => {
set({
timeRange: 'Custom range',
startDate: start,
endDate: end,
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
clearDateRange: () => {
set({
timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
setLevel: (level) => {
set({ level })
if (!get().isInitializing) {
get().syncWithURL()
}
},
setWorkflowIds: (workflowIds) => {
set({ workflowIds })
if (!get().isInitializing) {
get().syncWithURL()
}
},
toggleWorkflowId: (workflowId) => {
const currentWorkflowIds = [...get().workflowIds]
const index = currentWorkflowIds.indexOf(workflowId)
if (index === -1) {
currentWorkflowIds.push(workflowId)
} else {
currentWorkflowIds.splice(index, 1)
}
set({ workflowIds: currentWorkflowIds })
if (!get().isInitializing) {
get().syncWithURL()
}
},
setFolderIds: (folderIds) => {
set({ folderIds })
if (!get().isInitializing) {
get().syncWithURL()
}
},
toggleFolderId: (folderId) => {
const currentFolderIds = [...get().folderIds]
const index = currentFolderIds.indexOf(folderId)
if (index === -1) {
currentFolderIds.push(folderId)
} else {
currentFolderIds.splice(index, 1)
}
set({ folderIds: currentFolderIds })
if (!get().isInitializing) {
get().syncWithURL()
}
},
setSearchQuery: (searchQuery) => {
set({ searchQuery })
if (!get().isInitializing) {
get().syncWithURL()
}
},
setTriggers: (triggers: TriggerType[]) => {
set({ triggers })
if (!get().isInitializing) {
get().syncWithURL()
}
},
toggleTrigger: (trigger: TriggerType) => {
const currentTriggers = [...get().triggers]
const index = currentTriggers.indexOf(trigger)
if (index === -1) {
currentTriggers.push(trigger)
} else {
currentTriggers.splice(index, 1)
}
set({ triggers: currentTriggers })
if (!get().isInitializing) {
get().syncWithURL()
}
},
initializeFromURL: () => {
set({ isInitializing: true })
const params = getSearchParams()
const timeRange = parseTimeRangeFromURL(params.get('timeRange'))
const level = parseLogLevelFromURL(params.get('level'))
const workflowIds = parseStringArrayFromURL(params.get('workflowIds'))
const folderIds = parseStringArrayFromURL(params.get('folderIds'))
const triggers = parseTriggerArrayFromURL(params.get('triggers'))
const searchQuery = params.get('search') || ''
const startDate = params.get('startDate') || undefined
const endDate = params.get('endDate') || undefined
set({
timeRange,
startDate,
endDate,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
isInitializing: false,
})
},
resetFilters: () => {
set({
timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
level: 'all',
workflowIds: [],
folderIds: [],
triggers: [],
searchQuery: '',
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
syncWithURL: () => {
const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } =
get()
const params = new URLSearchParams()
if (timeRange !== DEFAULT_TIME_RANGE) {
params.set('timeRange', timeRangeToURL(timeRange))
}
if (timeRange === 'Custom range') {
if (startDate) {
params.set('startDate', startDate)
}
if (endDate) {
params.set('endDate', endDate)
}
}
if (level !== 'all') {
params.set('level', level)
}
if (workflowIds.length > 0) {
params.set('workflowIds', workflowIds.join(','))
}
if (folderIds.length > 0) {
params.set('folderIds', folderIds.join(','))
}
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
if (searchQuery.trim()) {
params.set('search', searchQuery.trim())
}
updateURL(params)
},
}))
)
+9 -29
View File
@@ -38,34 +38,14 @@ export type CoreTriggerType = (typeof CORE_TRIGGER_TYPES)[number]
export type TriggerType = CoreTriggerType | 'all' | (string & {})
/** Filter state for logs and dashboard views */
export interface FilterState {
workspaceId: string
viewMode: 'logs' | 'dashboard'
timeRange: TimeRange
startDate?: string
endDate?: string
level: LogLevel
workflowIds: string[]
folderIds: string[]
searchQuery: string
triggers: TriggerType[]
isInitializing: boolean
export type LogViewMode = 'logs' | 'dashboard'
setWorkspaceId: (workspaceId: string) => void
setViewMode: (viewMode: 'logs' | 'dashboard') => void
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
initializeFromURL: () => void
syncWithURL: () => void
resetFilters: () => void
/**
* Non-URL logs view state. The filter state itself (time range, level,
* workflows, folders, triggers, search) lives in the URL via nuqs
* (`useLogFilters`); only the view-mode toggle is kept in this store.
*/
export interface LogViewState {
viewMode: LogViewMode
setViewMode: (viewMode: LogViewMode) => void
}
+5
View File
@@ -225,6 +225,7 @@
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
"nodemailer": "8.0.9",
"nuqs": "2.8.9",
"officeparser": "^5.2.0",
"openai": "^4.91.1",
"pdf-lib": "1.17.1",
@@ -3092,6 +3093,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="],
"nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="],
"nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="],
@@ -4414,6 +4417,8 @@
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],