mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
+13
-16
@@ -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
|
||||
+65
-16
@@ -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'>
|
||||
|
||||
+49
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user