diff --git a/.agents/skills/you-might-not-need-url-state/SKILL.md b/.agents/skills/you-might-not-need-url-state/SKILL.md index 0c1296a5213..561829e4152 100644 --- a/.agents/skills/you-might-not-need-url-state/SKILL.md +++ b/.agents/skills/you-might-not-need-url-state/SKILL.md @@ -16,6 +16,10 @@ User arguments: $ARGUMENTS 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`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -33,14 +37,15 @@ Read these before analyzing: 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. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## 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. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. diff --git a/.claude/commands/you-might-not-need-url-state.md b/.claude/commands/you-might-not-need-url-state.md index bcab8ac2db6..309863683ea 100644 --- a/.claude/commands/you-might-not-need-url-state.md +++ b/.claude/commands/you-might-not-need-url-state.md @@ -15,6 +15,10 @@ User arguments: $ARGUMENTS 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`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -32,14 +36,15 @@ Read these before analyzing: 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. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## 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. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index b3be536a0a8..a841f40098c 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -127,42 +127,56 @@ If a client param must be re-read server-side after a change, set `shallow: fals ## 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. +Use `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect, and never hand-roll the debounce wiring inline. The nuqs value updates instantly (the input is controlled directly by it and stays snappy); only the *URL write* is debounced via nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options), which the hook applies for you. Clearing (or a whitespace-only value) writes `null` immediately so the param strips without lingering. -- **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 { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +// Search inside a grouped useQueryStates — the group's discrete filters keep immediate writes: +const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ search: value }, options)) - ```typescript - import { debounce } from 'nuqs' +// Standalone single param — pass the useQueryState setter directly: +const setSearch = useDebouncedSearchSetter(setSearchParam) - 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] - ) - ``` +// Non-default window (e.g. files' 200ms): +const setSearch = useDebouncedSearchSetter(write, { debounceMs: 200 }) +``` -- **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). +- **Never write a trimmed value to a param that controls the input** — trimming on write eats the user's trailing space mid-typing and makes multi-word queries untypable. The hook writes the raw value; trim only for the empty-check (the hook does this) and on *read* where the value feeds a query or filter. +- **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, SEARCH_DEBOUNCE_MS)` with `SEARCH_DEBOUNCE_MS` from `@/lib/url-state`) 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. +- Settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search` — the shared `?search=` binding for that surface. +- Preserve `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations (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: +Sortable lists use **two scalar params**, never a serialized `{column,direction}` object. Build them with `createSortParams` from `@/lib/url-state` (in the feature's `search-params.ts`) and consume them with `useUrlSort` from `@/hooks/use-url-sort` — never re-declare `SORT_DIRECTIONS`/default constants or hand-roll the `activeSort`/`onSort`/`onClear` wiring: ```typescript -const SORT_COLUMNS = ['name', 'created', 'updated'] as const -const SORT_DIRECTIONS = ['asc', 'desc'] as const +// search-params.ts (server-safe) +import { createSortParams } from '@/lib/url-state' -export const thingsParsers = { - sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'), - dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'), -} as const +const THING_SORT_COLUMNS = ['name', 'created', 'updated'] as const + +export const thingsSortParams = createSortParams(THING_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) ``` -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. +```typescript +// component (client) +import { useUrlSort } from '@/hooks/use-url-sort' + +const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams, thingsUrlKeys) +// activeSort/onSort/onClear plug straight into SortConfig; sort/dir feed query keys and comparators. +``` + +Two modes, chosen by whether you pass a default: + +- **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state. +- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). + +Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there. ## Dates in the URL (date-only params) diff --git a/.cursor/commands/you-might-not-need-url-state.md b/.cursor/commands/you-might-not-need-url-state.md index 60e96ca0bcf..330835125fa 100644 --- a/.cursor/commands/you-might-not-need-url-state.md +++ b/.cursor/commands/you-might-not-need-url-state.md @@ -10,6 +10,10 @@ User arguments: $ARGUMENTS 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`. +Shared helpers own the two repeated wirings — never hand-roll them inline: +- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column. +- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read. + `.claude/rules/sim-url-state.md` is the source of truth — read it first. ## References @@ -27,14 +31,15 @@ Read these before analyzing: 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. +6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. 8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. +10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. ## 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. +4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying. diff --git a/README.md b/README.md index 40118f75503..40dec306468 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Sim.ai Documentation - Discord + Slack X

diff --git a/apps/docs/components/docs-layout/page-footer.tsx b/apps/docs/components/docs-layout/page-footer.tsx index c7fd77d470c..c0796c1f359 100644 --- a/apps/docs/components/docs-layout/page-footer.tsx +++ b/apps/docs/components/docs-layout/page-footer.tsx @@ -24,9 +24,9 @@ const SOCIAL_LINKS = [ icon: 'M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z', }, { - href: 'https://discord.gg/Hr4UWYEcTT', - label: 'Discord', - icon: 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z', + href: 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA', + label: 'Slack', + icon: 'M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z', }, ] as const diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index fb8824b6806..1c20bb53d1b 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -86,7 +86,11 @@ const SOCIAL_LINKS: FooterItem[] = [ href: 'https://www.linkedin.com/company/simstudioai/', external: true, }, - { label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true }, + { + label: 'Slack', + href: 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA', + external: true, + }, { label: 'GitHub', href: 'https://github.com/simstudioai/sim', diff --git a/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx index 605d74b3412..7f312aadb7e 100644 --- a/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx @@ -110,4 +110,4 @@ docker compose logs -f simstudio ## Hilfe erhalten - [GitHub Issues](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx index c3b412052d5..2f833c6d7d7 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx @@ -107,4 +107,4 @@ docker compose logs -f simstudio ## Getting Help - [GitHub Issues](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx index 230d8a7b1c9..84fa3cdaeb0 100644 --- a/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx @@ -110,4 +110,4 @@ docker compose logs -f simstudio ## Obtener ayuda - [GitHub Issues](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx index 2f3d807cb2b..1d691f16cc1 100644 --- a/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx @@ -110,4 +110,4 @@ docker compose logs -f simstudio ## Obtenir de l'aide - [Problèmes GitHub](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx index 970b07d008f..24db6a65e23 100644 --- a/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx @@ -110,4 +110,4 @@ docker compose logs -f simstudio ## ヘルプを得る - [GitHub Issues](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx index 04933465fbc..83c639f7d83 100644 --- a/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx @@ -110,4 +110,4 @@ docker compose logs -f simstudio ## 获取帮助 - [GitHub Issues](https://github.com/simstudioai/sim/issues) -- [Discord](https://discord.gg/Hr4UWYEcTT) +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA) diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx index 2e8b50b2dd5..472f4aa75a4 100644 --- a/apps/sim/app/(landing)/blog/[slug]/page.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { notFound } from 'next/navigation' import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry' import { BLOG_SECTION, buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -18,6 +19,7 @@ export async function generateMetadata({ }): Promise { const { slug } = await params const post = await getPostBySlug(slug) + if (!post) return {} return buildPostMetadata(post) } @@ -26,6 +28,7 @@ export const revalidate = 86400 export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPostBySlug(slug) + if (!post) notFound() const related = await getRelatedPosts(slug, 3) return ( diff --git a/apps/sim/app/(landing)/blog/authors/[id]/page.tsx b/apps/sim/app/(landing)/blog/authors/[id]/page.tsx index 302b843cae6..77aea381d78 100644 --- a/apps/sim/app/(landing)/blog/authors/[id]/page.tsx +++ b/apps/sim/app/(landing)/blog/authors/[id]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { notFound } from 'next/navigation' import { getAllPostMeta } from '@/lib/blog/registry' import { BLOG_SECTION, buildAuthorGraphJsonLd, buildAuthorMetadata } from '@/lib/blog/seo' import { ContentAuthorPage } from '@/app/(landing)/components' @@ -11,22 +12,26 @@ export async function generateMetadata({ params: Promise<{ id: string }> }): Promise { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - return buildAuthorMetadata(id, posts[0]?.author) + const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id)) + const author = posts[0]?.authors.find((a) => a.id === id) + if (!author) return {} + return buildAuthorMetadata(id, author) } export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - const author = posts[0]?.author + const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id)) + const author = posts[0]?.authors.find((a) => a.id === id) + if (!author) notFound() return ( ) } diff --git a/apps/sim/app/(landing)/blog/authors/not-found.tsx b/apps/sim/app/(landing)/blog/authors/not-found.tsx new file mode 100644 index 00000000000..77b14ef9f97 --- /dev/null +++ b/apps/sim/app/(landing)/blog/authors/not-found.tsx @@ -0,0 +1,26 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Page Not Found', + robots: { index: false, follow: true }, +} + +export default function BlogAuthorNotFound() { + return ( +
+

+ Author not found +

+

+ The author you're looking for doesn't exist or has been moved. +

+ + Browse blog + +
+ ) +} diff --git a/apps/sim/app/(landing)/blog/not-found.tsx b/apps/sim/app/(landing)/blog/not-found.tsx new file mode 100644 index 00000000000..70ea50b3f62 --- /dev/null +++ b/apps/sim/app/(landing)/blog/not-found.tsx @@ -0,0 +1,26 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Page Not Found', + robots: { index: false, follow: true }, +} + +export default function BlogNotFound() { + return ( +
+

+ Post not found +

+

+ The post you're looking for doesn't exist or has been moved. +

+ + Browse blog + +
+ ) +} diff --git a/apps/sim/app/(landing)/comparison/[provider]/page.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx similarity index 94% rename from apps/sim/app/(landing)/comparison/[provider]/page.tsx rename to apps/sim/app/(landing)/comparisons/[provider]/page.tsx index cafff5b7347..2209e94a97c 100644 --- a/apps/sim/app/(landing)/comparison/[provider]/page.tsx +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx @@ -4,10 +4,10 @@ import type { CompetitorProfile } from '@/lib/compare/data' import { simProfile } from '@/lib/compare/data' import { SITE_URL } from '@/lib/core/utils/urls' import { buildLandingMetadata } from '@/lib/landing/seo' -import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections' -import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' -import { ComparisonCards } from '@/app/(landing)/comparison/components/comparison-cards' -import { ComparisonTable } from '@/app/(landing)/comparison/components/comparison-table' +import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/comparison-sections' +import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile' +import { ComparisonCards } from '@/app/(landing)/comparisons/components/comparison-cards' +import { ComparisonTable } from '@/app/(landing)/comparisons/components/comparison-table' import { ALL_COMPETITORS, buildBottomLine, @@ -15,7 +15,7 @@ import { getCompetitorBySlug, getLatestVerifiedDate, SIM_LATEST_VERIFIED, -} from '@/app/(landing)/comparison/utils' +} from '@/app/(landing)/comparisons/utils' import { BackLink } from '@/app/(landing)/components' import { Cta } from '@/app/(landing)/components/cta/cta' import { JsonLd } from '@/app/(landing)/components/json-ld' @@ -57,7 +57,7 @@ export async function generateMetadata({ return buildLandingMetadata({ title: `Sim vs ${competitor.name} | Sim, the AI Workspace`, description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`, - path: `/comparison/${competitor.id}`, + path: `/comparisons/${competitor.id}`, keywords: [ `Sim vs ${competitor.name}`, `${competitor.name} alternative`, @@ -91,12 +91,12 @@ export default async function ComparisonProviderPage({ '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl }, - { '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` }, + { '@type': 'ListItem', position: 2, name: 'Comparisons', item: `${baseUrl}/comparisons` }, { '@type': 'ListItem', position: 3, name: `Sim vs ${competitor.name}`, - item: `${baseUrl}/comparison/${competitor.id}`, + item: `${baseUrl}/comparisons/${competitor.id}`, }, ], } @@ -110,7 +110,7 @@ export default async function ComparisonProviderPage({ '@type': 'ItemList', name: `Sim vs ${competitor.name}`, description: `Feature and pricing comparison between Sim and ${competitor.name}.`, - url: `${baseUrl}/comparison/${competitor.id}`, + url: `${baseUrl}/comparisons/${competitor.id}`, dateModified: latestVerified.toISOString().slice(0, 10), numberOfItems: 2, itemListElement: [ @@ -163,7 +163,7 @@ export default async function ComparisonProviderPage({
- +
diff --git a/apps/sim/app/(landing)/comparison/comparison-sections.ts b/apps/sim/app/(landing)/comparisons/comparison-sections.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/comparison-sections.ts rename to apps/sim/app/(landing)/comparisons/comparison-sections.ts diff --git a/apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx b/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx similarity index 100% rename from apps/sim/app/(landing)/comparison/components/brand-icon-tile/brand-icon-tile.tsx rename to apps/sim/app/(landing)/comparisons/components/brand-icon-tile/brand-icon-tile.tsx diff --git a/apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts b/apps/sim/app/(landing)/comparisons/components/brand-icon-tile/index.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/components/brand-icon-tile/index.ts rename to apps/sim/app/(landing)/comparisons/components/brand-icon-tile/index.ts diff --git a/apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx b/apps/sim/app/(landing)/comparisons/components/comparison-cards/comparison-cards.tsx similarity index 95% rename from apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx rename to apps/sim/app/(landing)/comparisons/components/comparison-cards/comparison-cards.tsx index 8a3d62e4745..d50a7973ab7 100644 --- a/apps/sim/app/(landing)/comparison/components/comparison-cards/comparison-cards.tsx +++ b/apps/sim/app/(landing)/comparisons/components/comparison-cards/comparison-cards.tsx @@ -1,5 +1,5 @@ import type { FactSource } from '@/lib/compare/data' -import { SourceLink } from '@/app/(landing)/comparison/components/source-info' +import { SourceLink } from '@/app/(landing)/comparisons/components/source-info' interface ComparisonCardItem { title: string diff --git a/apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts b/apps/sim/app/(landing)/comparisons/components/comparison-cards/index.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/components/comparison-cards/index.ts rename to apps/sim/app/(landing)/comparisons/components/comparison-cards/index.ts diff --git a/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx b/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx similarity index 98% rename from apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx rename to apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx index 2b13db94836..a5208b43ab9 100644 --- a/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx +++ b/apps/sim/app/(landing)/comparisons/components/comparison-table/comparison-table.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' import type { CompetitorProfile } from '@/lib/compare/data' -import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections' -import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' -import { FactValue } from '@/app/(landing)/comparison/components/fact-value' +import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparisons/comparison-sections' +import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile' +import { FactValue } from '@/app/(landing)/comparisons/components/fact-value' export interface ComparisonTableProps { sim: CompetitorProfile diff --git a/apps/sim/app/(landing)/comparison/components/comparison-table/index.ts b/apps/sim/app/(landing)/comparisons/components/comparison-table/index.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/components/comparison-table/index.ts rename to apps/sim/app/(landing)/comparisons/components/comparison-table/index.ts diff --git a/apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx b/apps/sim/app/(landing)/comparisons/components/fact-value/fact-value.tsx similarity index 94% rename from apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx rename to apps/sim/app/(landing)/comparisons/components/fact-value/fact-value.tsx index 2e95baf01dd..88f1f6f01ef 100644 --- a/apps/sim/app/(landing)/comparison/components/fact-value/fact-value.tsx +++ b/apps/sim/app/(landing)/comparisons/components/fact-value/fact-value.tsx @@ -1,7 +1,7 @@ import { Check, X } from '@sim/emcn/icons' import type { Fact } from '@/lib/compare/data' -import { SourceLink } from '@/app/(landing)/comparison/components/source-info' -import { parseFactValue } from '@/app/(landing)/comparison/fact-status' +import { SourceLink } from '@/app/(landing)/comparisons/components/source-info' +import { parseFactValue } from '@/app/(landing)/comparisons/fact-status' export interface FactValueProps { fact: Fact diff --git a/apps/sim/app/(landing)/comparison/components/fact-value/index.ts b/apps/sim/app/(landing)/comparisons/components/fact-value/index.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/components/fact-value/index.ts rename to apps/sim/app/(landing)/comparisons/components/fact-value/index.ts diff --git a/apps/sim/app/(landing)/comparison/components/source-info/index.ts b/apps/sim/app/(landing)/comparisons/components/source-info/index.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/components/source-info/index.ts rename to apps/sim/app/(landing)/comparisons/components/source-info/index.ts diff --git a/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx b/apps/sim/app/(landing)/comparisons/components/source-info/source-info.tsx similarity index 100% rename from apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx rename to apps/sim/app/(landing)/comparisons/components/source-info/source-info.tsx diff --git a/apps/sim/app/(landing)/comparison/fact-status.ts b/apps/sim/app/(landing)/comparisons/fact-status.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/fact-status.ts rename to apps/sim/app/(landing)/comparisons/fact-status.ts diff --git a/apps/sim/app/(landing)/comparison/not-found.tsx b/apps/sim/app/(landing)/comparisons/not-found.tsx similarity index 91% rename from apps/sim/app/(landing)/comparison/not-found.tsx rename to apps/sim/app/(landing)/comparisons/not-found.tsx index 8a22c0f44c6..79f529b3b46 100644 --- a/apps/sim/app/(landing)/comparison/not-found.tsx +++ b/apps/sim/app/(landing)/comparisons/not-found.tsx @@ -18,7 +18,7 @@ export default function ComparisonNotFound() {

The comparison you're looking for doesn't exist or has been moved.

- + Browse comparisons
diff --git a/apps/sim/app/(landing)/comparison/page.tsx b/apps/sim/app/(landing)/comparisons/page.tsx similarity index 95% rename from apps/sim/app/(landing)/comparison/page.tsx rename to apps/sim/app/(landing)/comparisons/page.tsx index 973911e78e4..fdf95f5619b 100644 --- a/apps/sim/app/(landing)/comparison/page.tsx +++ b/apps/sim/app/(landing)/comparisons/page.tsx @@ -3,8 +3,8 @@ import Link from 'next/link' import { simProfile } from '@/lib/compare/data' import { SITE_URL } from '@/lib/core/utils/urls' import { buildLandingMetadata } from '@/lib/landing/seo' -import { BrandIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' -import { ALL_COMPETITORS, ensurePeriod, lowercaseFirst } from '@/app/(landing)/comparison/utils' +import { BrandIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile' +import { ALL_COMPETITORS, ensurePeriod, lowercaseFirst } from '@/app/(landing)/comparisons/utils' import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow' import { JsonLd } from '@/app/(landing)/components/json-ld' import { LandingFAQ } from '@/app/(landing)/components/landing-faq' @@ -47,7 +47,7 @@ export const metadata: Metadata = buildLandingMetadata({ title: 'Sim Comparisons | Sim, the AI Workspace', description: 'Compare Sim, the open-source AI workspace, to n8n, Zapier, Make, and other workflow automation and AI agent platforms. Sourced, dated, fact-checked.', - path: '/comparison', + path: '/comparisons', keywords: [ 'Sim comparison', 'Sim vs n8n', @@ -65,7 +65,7 @@ export default function ComparisonHubPage() { '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl }, - { '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` }, + { '@type': 'ListItem', position: 2, name: 'Comparisons', item: `${baseUrl}/comparisons` }, ], } @@ -74,12 +74,12 @@ export default function ComparisonHubPage() { '@type': 'ItemList', name: 'Sim Comparisons', description: 'Directory of Sim comparison pages against AI agent and workflow platforms.', - url: `${baseUrl}/comparison`, + url: `${baseUrl}/comparisons`, numberOfItems: ALL_COMPETITORS.length, itemListElement: ALL_COMPETITORS.map((competitor, index) => ({ '@type': 'ListItem', position: index + 1, - url: `${baseUrl}/comparison/${competitor.id}`, + url: `${baseUrl}/comparisons/${competitor.id}`, name: `Sim vs ${competitor.name}`, })), } @@ -144,7 +144,7 @@ export default function ComparisonHubPage() { return (
diff --git a/apps/sim/app/(landing)/comparison/utils.ts b/apps/sim/app/(landing)/comparisons/utils.ts similarity index 100% rename from apps/sim/app/(landing)/comparison/utils.ts rename to apps/sim/app/(landing)/comparisons/utils.ts diff --git a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx index 0d40831d37a..8ba0d5e00eb 100644 --- a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx +++ b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx @@ -5,22 +5,34 @@ const AUTHOR_POST_SKELETON_COUNT = 4 /** Shared loading skeleton for a content section's author-profile route. */ export function ContentAuthorLoading() { return ( -
-
- - +
+
+ +
+ + +
-
- {Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => ( -
- -
- - + +
+ +
+
+ {Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => ( +
+
+ +
+ + +
+ +
+
-
- ))} + ))} +
-
+ ) } diff --git a/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx b/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx index e57ad9e3c64..db9b21b2a99 100644 --- a/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx +++ b/apps/sim/app/(landing)/components/content-author-page/content-author-page.tsx @@ -1,76 +1,120 @@ import Image from 'next/image' import Link from 'next/link' import type { ContentMeta } from '@/lib/content/schema' +import { BackLink } from '@/app/(landing)/components/back-link' +import { Cta } from '@/app/(landing)/components/cta/cta' import { JsonLd } from '@/app/(landing)/components/json-ld' interface ContentAuthorPageProps { /** Route base path, e.g. `/blog` or `/library`. */ basePath: string - authorName?: string + /** Section label used in the back link, e.g. "Blog" or "Library". */ + sectionName: string + authorName: string authorAvatarUrl?: string /** Posts already filtered down to this author. */ posts: ContentMeta[] graphJsonLd?: Record } -/** Shared author-profile layout for a content section. */ +/** + * Shared author-profile layout for a content section: standard page-shell + * header (matching `ContentIndexPage`/`ContentPostPage`) with avatar + name, + * followed by the author's posts in the same framed-list card style used + * everywhere else on the site. + */ export function ContentAuthorPage({ basePath, + sectionName, authorName, authorAvatarUrl, posts, graphJsonLd, }: ContentAuthorPageProps) { - if (!authorName) { - return ( -
-

Author not found

-
- ) - } - return ( -
- {graphJsonLd && } -
- {authorAvatarUrl ? ( - {authorName} - ) : null} -

{authorName}

-
-
- {posts.map((p) => ( - -
+ <> +
+ {graphJsonLd && } + +
+
+ +
+ +
+ {authorAvatarUrl ? ( {p.title} -
-
- {new Date(p.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -
-
{p.title}
+ ) : null} +

+ {authorName} +

+
+
+ +
+ +
+
+ {posts.map((p) => ( +
+ + + {new Date(p.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + +
+ + {new Date(p.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +

+ {p.title} +

+

+ {p.description} +

+
+ +
+ {p.title} +
+ +
-
- - ))} + ))} +
+
+ +
+
+ +
+
-
+ ) } diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx index e7ab328e016..fccff3158de 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx @@ -96,9 +96,8 @@ export function ContentPostPage({ ) : null} setParams({ q: value }, options)) + /** Category facets, derived once from the (stable) integration list. */ const availableCategories = useMemo(() => { const counts = new Map() @@ -66,9 +67,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) { type='search' placeholder='Search integrations, tools, or triggers…' value={query} - onChange={(e) => - setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }) - } + onChange={(e) => setQuery(e.target.value)} aria-label='Search integrations' />
diff --git a/apps/sim/app/(landing)/library/[slug]/page.tsx b/apps/sim/app/(landing)/library/[slug]/page.tsx index bd8de683cc0..e6901ee428c 100644 --- a/apps/sim/app/(landing)/library/[slug]/page.tsx +++ b/apps/sim/app/(landing)/library/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { notFound } from 'next/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/library/registry' import { buildPostGraphJsonLd, buildPostMetadata, LIBRARY_SECTION } from '@/lib/library/seo' @@ -18,6 +19,7 @@ export async function generateMetadata({ }): Promise { const { slug } = await params const post = await getPostBySlug(slug) + if (!post) return {} return buildPostMetadata(post) } @@ -26,6 +28,7 @@ export const revalidate = 86400 export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPostBySlug(slug) + if (!post) notFound() const related = await getRelatedPosts(slug, 3) return ( diff --git a/apps/sim/app/(landing)/library/authors/[id]/page.tsx b/apps/sim/app/(landing)/library/authors/[id]/page.tsx index 6462c378525..7af8169eb9d 100644 --- a/apps/sim/app/(landing)/library/authors/[id]/page.tsx +++ b/apps/sim/app/(landing)/library/authors/[id]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { notFound } from 'next/navigation' import { getAllPostMeta } from '@/lib/library/registry' import { buildAuthorGraphJsonLd, buildAuthorMetadata, LIBRARY_SECTION } from '@/lib/library/seo' import { ContentAuthorPage } from '@/app/(landing)/components' @@ -11,22 +12,26 @@ export async function generateMetadata({ params: Promise<{ id: string }> }): Promise { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - return buildAuthorMetadata(id, posts[0]?.author) + const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id)) + const author = posts[0]?.authors.find((a) => a.id === id) + if (!author) return {} + return buildAuthorMetadata(id, author) } export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - const author = posts[0]?.author + const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id)) + const author = posts[0]?.authors.find((a) => a.id === id) + if (!author) notFound() return ( ) } diff --git a/apps/sim/app/(landing)/library/authors/not-found.tsx b/apps/sim/app/(landing)/library/authors/not-found.tsx new file mode 100644 index 00000000000..9207370e8c1 --- /dev/null +++ b/apps/sim/app/(landing)/library/authors/not-found.tsx @@ -0,0 +1,26 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Page Not Found', + robots: { index: false, follow: true }, +} + +export default function LibraryAuthorNotFound() { + return ( +
+

+ Author not found +

+

+ The author you're looking for doesn't exist or has been moved. +

+ + Browse library + +
+ ) +} diff --git a/apps/sim/app/(landing)/library/not-found.tsx b/apps/sim/app/(landing)/library/not-found.tsx new file mode 100644 index 00000000000..9bca5460c33 --- /dev/null +++ b/apps/sim/app/(landing)/library/not-found.tsx @@ -0,0 +1,26 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Page Not Found', + robots: { index: false, follow: true }, +} + +export default function LibraryNotFound() { + return ( +
+

+ Post not found +

+

+ The post you're looking for doesn't exist or has been moved. +

+ + Browse library + +
+ ) +} diff --git a/apps/sim/app/(landing)/models/components/model-directory.tsx b/apps/sim/app/(landing)/models/components/model-directory.tsx index 2bbdbb14311..60ce1eedfde 100644 --- a/apps/sim/app/(landing)/models/components/model-directory.tsx +++ b/apps/sim/app/(landing)/models/components/model-directory.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { ChipInput, Search } from '@sim/emcn' import Link from 'next/link' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow' import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives' import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params' @@ -15,9 +15,7 @@ import { MODEL_PROVIDERS_WITH_CATALOGS, MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS, } from '@/app/(landing)/models/utils' - -/** Debounce window for writing the search term to the URL (filtering is instant). */ -const SEARCH_DEBOUNCE_MS = 300 +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({ id: provider.id, @@ -29,6 +27,9 @@ export function ModelDirectory() { const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys) const activeProviderId = provider || null + /** Debounced `q` URL write; the input stays instant and clearing strips the param immediately. */ + const setQuery = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options)) + const normalizedQuery = query.trim().toLowerCase() const { filteredProviders, filteredDynamicProviders } = useMemo(() => { @@ -90,12 +91,7 @@ export function ModelDirectory() { type='search' placeholder='Search models, providers, or capabilities…' value={query} - onChange={(event) => - setParams( - { q: event.target.value }, - { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - } + onChange={(event) => setQuery(event.target.value)} aria-label='Search AI models' /> diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 4d883e9ee3a..71f10cbea0e 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer' @@ -50,5 +51,15 @@ export default async function AccountSettingsSectionPage({ if (!isSuperUser) notFound() } - return + /** + * Sections read URL query params via nuqs (which uses `useSearchParams` + * internally), so the renderer must sit under a Suspense boundary. The + * `null` fallback matches the existing visual behavior — the sections are + * `next/dynamic` components that render nothing while their chunk loads. + */ + return ( + + + + ) } diff --git a/apps/sim/app/llms-full.txt/route.ts b/apps/sim/app/llms-full.txt/route.ts index 22718fdd2b1..f39b43d10de 100644 --- a/apps/sim/app/llms-full.txt/route.ts +++ b/apps/sim/app/llms-full.txt/route.ts @@ -154,14 +154,14 @@ Built-in table creation and management: - [Documentation](https://docs.sim.ai): Product guides and technical reference - [API Reference](https://docs.sim.ai/api): API documentation - [GitHub](https://github.com/simstudioai/sim): Open-source codebase -- [Discord](https://discord.gg/Hr4UWYEcTT): Community server +- [Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA): Community workspace - [X/Twitter](https://x.com/simdotai): Announcements and updates - [LinkedIn](https://linkedin.com/company/simstudioai): Company page ## Support - [Documentation](https://docs.sim.ai): Self-serve guides and reference -- [Community Discord](https://discord.gg/Hr4UWYEcTT): Community support +- [Community Slack](https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA): Community support - Email: help@sim.ai - Security issues: security@sim.ai diff --git a/apps/sim/app/seo.test.ts b/apps/sim/app/seo.test.ts index f001f8d9766..38b0dfecf71 100644 --- a/apps/sim/app/seo.test.ts +++ b/apps/sim/app/seo.test.ts @@ -92,7 +92,7 @@ describe('SEO canonical URLs', () => { if (!hasBareSimAi) continue const isAllowlisted = - line.includes('https://sim.ai/careers') || line.includes('https://sim.ai/discord') + line.includes('https://sim.ai/careers') || line.includes('https://sim.ai/slack') if (isAllowlisted) continue diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 30f3c91042c..1bd7a26efa5 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -9,7 +9,7 @@ import { ALL_COMPETITORS, getLatestVerifiedDate, SIM_LATEST_VERIFIED, -} from '@/app/(landing)/comparison/utils' +} from '@/app/(landing)/comparisons/utils' import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' /** One sitemap entry per author, timestamped by their most recently updated post. */ @@ -25,7 +25,7 @@ function buildAuthorPages(posts: ContentMeta[], basePath: string): MetadataRoute } } return [...authorsMap.entries()].map(([id, date]) => ({ - url: `${SITE_URL}${basePath}/authors/${id}`, + url: `${SITE_URL}${basePath}/authors/${encodeURIComponent(id)}`, lastModified: date, })) } @@ -172,9 +172,9 @@ export default async function sitemap(): Promise { : SIM_LATEST_VERIFIED const comparisonPages: MetadataRoute.Sitemap = [ - { url: `${baseUrl}/comparison`, lastModified: comparisonLastModified }, + { url: `${baseUrl}/comparisons`, lastModified: comparisonLastModified }, ...ALL_COMPETITORS.map((competitor) => ({ - url: `${baseUrl}/comparison/${competitor.id}`, + url: `${baseUrl}/comparisons/${competitor.id}`, lastModified: competitorLastModified(competitor), })), ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 1d55ca37628..c6bcecde52d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -75,7 +75,13 @@ 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 { + filesFilterParsers, + filesFilterUrlKeys, + filesParsers, + filesSortParams, + filesUrlKeys, +} from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' @@ -94,8 +100,10 @@ import { useWorkspaceFiles, } from '@/hooks/queries/workspace-files' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' type FileResourceItem = @@ -104,6 +112,12 @@ type FileResourceItem = const logger = createLogger('Files') +/** + * Debounce window for `search` URL writes and filtering; the input itself stays + * instant. Intentionally shorter than the shared `SEARCH_DEBOUNCE_MS` (300). + */ +const FILES_SEARCH_DEBOUNCE_MS = 200 as const + const SUPPORTED_EXTENSIONS = [ ...SUPPORTED_DOCUMENT_EXTENSIONS, ...SUPPORTED_CODE_EXTENSIONS, @@ -244,15 +258,42 @@ export function Files() { }) const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) - const [inputValue, setInputValue] = useState('') - const debouncedSearchTerm = useDebounce(inputValue, 200) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) - const [typeFilter, setTypeFilter] = useState([]) - const [sizeFilter, setSizeFilter] = useState([]) - const [uploadedByFilter, setUploadedByFilter] = useState([]) + const [ + { search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter }, + setFileFilters, + ] = useQueryStates(filesFilterParsers, filesFilterUrlKeys) + + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The in-memory filter below still reads a debounced value + * so it doesn't recompute on every keystroke. + */ + const setSearchTerm = useDebouncedSearchSetter( + (value, options) => setFileFilters({ search: value }, options), + { debounceMs: FILES_SEARCH_DEBOUNCE_MS } + ) + const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) + + /** + * `sort`/`dir` are nullable in the URL because "no active sort" is distinct + * from an explicit updated/desc selection: with no sort, files fall back to + * updated/desc but folders to name/asc, while an explicit sort orders both + * sections by the chosen column. + */ + const { activeSort, onSort, onClear } = useUrlSort(filesSortParams, filesFilterUrlKeys) + + const setTypeFilter = useCallback( + (next: string[]) => setFileFilters({ type: next }), + [setFileFilters] + ) + const setSizeFilter = useCallback( + (next: string[]) => setFileFilters({ size: next }), + [setFileFilters] + ) + const setUploadedByFilter = useCallback( + (next: string[]) => setFileFilters({ uploadedBy: next }), + [setFileFilters] + ) const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) @@ -347,10 +388,9 @@ export function Files() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const searched = debouncedSearchTerm - ? siblings.filter((folder) => - folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) - ) + const needle = debouncedSearchTerm.trim().toLowerCase() + const searched = needle + ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings const col = activeSort?.column ?? 'name' const dir = activeSort?.direction ?? 'asc' @@ -368,11 +408,10 @@ export function Files() { }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) const filteredFiles = useMemo(() => { - let result = debouncedSearchTerm + const needle = debouncedSearchTerm.trim().toLowerCase() + let result = needle ? files.filter( - (f) => - (f.folderId ?? null) === currentFolderId && - f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + (f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle) ) : files.filter((f) => (f.folderId ?? null) === currentFolderId) @@ -1523,9 +1562,9 @@ export function Files() { }, [canEdit, uploading]) const searchConfig: SearchConfig = { - value: inputValue, - onChange: setInputValue, - onClearAll: () => setInputValue(''), + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), placeholder: 'Search files...', } @@ -1689,10 +1728,10 @@ export function Files() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + onSort, + onClear, }), - [activeSort] + [activeSort, onSort, onClear] ) const hasActiveFilters = diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts index d1c35f80e58..8ef1d7c3803 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -1,4 +1,8 @@ -import { createParser, parseAsString } from 'nuqs/server' +import { createParser, parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' + +/** Sortable list columns, matching the `Resource.Options` sort menu. */ +export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const /** * Parser for the `new` flag. Preserves the prior `?new=1` wire format on @@ -47,3 +51,41 @@ export const filesUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * Co-located, typed URL query-param definitions for the Files list's + * filter/search/sort view-state, grouped separately from the navigation params + * above because filter writes must never land in the browser history. + * + * - `search` is the file/folder name filter. The input is controlled directly + * by the nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. + * - `type` filters by file kind (document/image/audio/video); `size` filters by + * size bucket (small/medium/large); `uploadedBy` filters by uploader user id + * (URL key `uploaded-by`). All three are multi-select arrays. + */ +export const filesFilterParsers = { + search: parseAsString.withDefault(''), + type: parseAsArrayOf(parseAsString).withDefault([]), + size: parseAsArrayOf(parseAsString).withDefault([]), + uploadedBy: parseAsArrayOf(parseAsString).withDefault([]), +} as const + +/** + * `sort` / `dir` follow the shared sort convention (two scalar params) in + * nullable mode (no `defaultSort`) because "no active sort" is behaviorally + * distinct from explicitly sorting by the fallback column: with no sort, files + * order by updated/desc but folders by name/asc, while an explicit updated/desc + * sorts both sections by updatedAt. Collapsing the explicit selection into a + * clean URL would make that folder ordering unreachable. Clearing the sort + * writes `null`, which strips both params. + */ +export const filesSortParams = createSortParams(FILE_SORT_COLUMNS) + +/** Filter/search/sort view-state: clean URLs, no back-stack churn. */ +export const filesFilterUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, + urlKeys: { uploadedBy: 'uploaded-by' }, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 74317dab9de..9a62f40f7cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -3,8 +3,10 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' import { ShimmerText } from '@/components/ui' +import { useSmoothText } from '@/hooks/use-smooth-text' import type { ToolCallData } from '../../../../types' import { getAgentIcon, isToolDone } from '../../utils' +import { renderInlineMarkdown } from './inline-markdown' import { ToolCallItem } from './tool-call-item' /** @@ -148,12 +150,11 @@ export function AgentGroup({ ) } return ( - - {item.content.trim()} - + content={item.content} + isStreaming={isStreaming && idx === items.length - 1} + /> ) })} @@ -165,6 +166,27 @@ export function AgentGroup({ ) } +interface NarrationTextProps { + content: string + /** This row is the group's live tail — pace its reveal like top-level text. */ + isStreaming: boolean +} + +/** + * A narration (thinking/text) row inside an agent group. The live tail row is + * paced with {@link useSmoothText} so streamed chunks reveal word-by-word + * instead of popping in, matching the top-level text treatment. + */ +function NarrationText({ content, isStreaming }: NarrationTextProps) { + const revealed = useSmoothText(content, isStreaming) + + return ( + + {renderInlineMarkdown(revealed.trim())} + + ) +} + interface BoundedViewportProps { children: React.ReactNode isStreaming: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx new file mode 100644 index 00000000000..7cd670a95c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { isValidElement } from 'react' +import { describe, expect, it } from 'vitest' +import { renderInlineMarkdown } from './inline-markdown' + +function flattenText(node: React.ReactNode): string { + if (typeof node === 'string') return node + if (Array.isArray(node)) return node.map(flattenText).join('') + if (isValidElement<{ children?: React.ReactNode }>(node)) return flattenText(node.props.children) + return '' +} + +function findByType(parts: React.ReactNode[], type: string): React.ReactNode { + return parts.find((p) => isValidElement(p) && p.type === type) +} + +describe('renderInlineMarkdown', () => { + it('renders **bold** spans as strong elements', () => { + const parts = renderInlineMarkdown('The failing block is **ModalDenied** (a Slack block).') + const bold = findByType(parts, 'strong') + expect(bold).toBeDefined() + expect(flattenText(bold)).toBe('ModalDenied') + expect(flattenText(parts)).toBe('The failing block is ModalDenied (a Slack block).') + }) + + it('renders `code` spans as mono elements', () => { + const parts = renderInlineMarkdown('check the `webhook` payload') + expect(flattenText(findByType(parts, 'span'))).toBe('webhook') + expect(flattenText(parts)).toBe('check the webhook payload') + }) + + it('renders *italic* spans as em elements', () => { + const parts = renderInlineMarkdown('this is *important* context') + expect(flattenText(findByType(parts, 'em'))).toBe('important') + }) + + it('renders ***bold-italic*** as nested strong and em', () => { + const parts = renderInlineMarkdown('a ***wrapped*** word') + const bold = findByType(parts, 'strong') + expect(bold).toBeDefined() + expect(flattenText(parts)).toBe('a wrapped word') + }) + + it('renders links as their label text', () => { + const parts = renderInlineMarkdown('see [the docs](https://sim.ai/docs) for more') + expect(flattenText(parts)).toBe('see the docs for more') + }) + + it('keeps emphasis markers inside code spans verbatim', () => { + const parts = renderInlineMarkdown('pass `*args` and `**kwargs` through') + const codeTexts = parts + .filter((p) => isValidElement(p) && p.type === 'span') + .map((p) => flattenText(p)) + expect(codeTexts).toEqual(['*args', '**kwargs']) + expect(flattenText(parts)).toBe('pass *args and **kwargs through') + }) + + it('renders nested markers inside emphasis and link labels', () => { + expect( + flattenText(renderInlineMarkdown('read [**the docs**](https://sim.ai/docs) first')) + ).toBe('read the docs first') + expect(flattenText(renderInlineMarkdown('use *`glob`* patterns'))).toBe('use glob patterns') + }) + + it('leaves unterminated markers verbatim', () => { + expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker']) + expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick']) + }) + + it('does not italicize bare asterisks in math-like text', () => { + expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4']) + }) + + it('never reclassifies plain text the tokenizer rejected', () => { + expect(renderInlineMarkdown('* x *')).toEqual(['* x *']) + expect(renderInlineMarkdown('** spaced bullets **')).toEqual(['** spaced bullets **']) + }) + + it('passes plain text through untouched', () => { + expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx new file mode 100644 index 00000000000..591745ef329 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx @@ -0,0 +1,56 @@ +import { Fragment, type ReactNode } from 'react' + +const INLINE_TOKEN = + /(\*{3}[^\s*](?:[^*\n]*[^\s*])?\*{3}|\*\*[^\s*](?:[^*\n]*[^\s*])?\*\*|\*[^\s*](?:[^*\n]*[^\s*])?\*|`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g + +const LINK_TOKEN = /^\[([^\]\n]+)\]\([^\s)]+\)$/ + +/** + * Minimal inline-markdown renderer for agent-group narration rows. Supports + * `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and + * `[label](url)` links (rendered as their label — narration is prose, not + * navigation). Emphasis contents and link labels are rendered recursively so + * nested markers resolve; code spans stay verbatim. Everything else, + * including unterminated markers, renders as-is. Full Streamdown rendering is + * intentionally avoided here — these rows re-render on every streaming frame. + * + * Splitting on a single capturing group alternates plain text (even indices) + * and matched tokens (odd indices), so index parity is the exact + * discriminator — plain text that merely resembles a marker (e.g. `* x *`, + * rejected by the tokenizer's boundary rules) is never reclassified. + */ +export function renderInlineMarkdown(text: string): ReactNode[] { + return text.split(INLINE_TOKEN).map((part, i) => (i % 2 === 1 ? renderToken(part, i) : part)) +} + +function renderToken(part: string, key: number): ReactNode { + if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) { + return ( + + {renderInlineMarkdown(part.slice(3, -3))} + + ) + } + if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) { + return ( + + {renderInlineMarkdown(part.slice(2, -2))} + + ) + } + if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ) + } + if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) { + return {renderInlineMarkdown(part.slice(1, -1))} + } + const link = LINK_TOKEN.exec(part) + if (link) { + return {renderInlineMarkdown(link[1])} + } + return part +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 59d9bf69dd7..90776627f57 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -1,30 +1,9 @@ import { useMemo } from 'react' import { ShimmerText } from '@/components/ui' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display' import type { ToolCallStatus } from '../../../../types' -import { getToolIcon, resolveToolDisplayState } from '../../utils' - -function CircleCheck({ className }: { className?: string }) { - return ( - - - - - ) -} +import { resolveToolDisplayState } from '../../utils' export function CircleStop({ className }: { className?: string }) { return ( @@ -42,58 +21,6 @@ export function CircleStop({ className }: { className?: string }) { ) } -function Hyphen({ className }: { className?: string }) { - return ( - - - - ) -} - -function CircleOutline({ className }: { className?: string }) { - return ( - - - - ) -} - -function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) { - const display = resolveToolDisplayState(status) - if (display === 'spinner') { - const Icon = getToolIcon(toolName) - if (Icon) { - return - } - return - } - if (display === 'cancelled') { - return - } - if (display === 'interrupted') { - return - } - const Icon = getToolIcon(toolName) - if (Icon) { - return - } - return -} - interface ToolCallItemProps { toolName: string displayTitle: string @@ -101,6 +28,12 @@ interface ToolCallItemProps { streamingArgs?: string } +/** + * A single tool-call row inside an agent group: shimmer while executing, a + * static label once terminal. For `workspace_file` the title is derived live + * from the streaming args; because that path bypasses the completed-title + * rewrite in `toToolData`, the past-tense flip is applied here on success. + */ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: ToolCallItemProps) { const liveWorkspaceFileTitle = useMemo(() => { if (toolName !== WorkspaceFile.id || !streamingArgs) return null @@ -132,13 +65,14 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: }, [toolName, streamingArgs]) const isExecuting = resolveToolDisplayState(status) === 'spinner' - const title = liveWorkspaceFileTitle || displayTitle + const liveTitle = liveWorkspaceFileTitle || displayTitle + const title = + status === 'success' && liveWorkspaceFileTitle + ? (getToolCompletedTitle(liveTitle) ?? liveTitle) + : liveTitle return ( -
-
- -
+
{isExecuting ? ( {title} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index b6a93bfbb34..20f778622db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -266,3 +266,123 @@ describe('shouldSmoothTextSegment', () => { ) }) }) + +describe('completed tool titles', () => { + function queryLogsCall(status: 'executing' | 'success' | 'error', displayTitle?: string) { + return { + type: 'tool_call' as const, + toolCall: { id: 't1', name: 'query_logs', status, displayTitle }, + timestamp: 1, + } + } + + function firstToolTitle(blocks: ContentBlock[]): string { + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const tool = group.items.find((i) => i.type === 'tool') + if (!tool || tool.type !== 'tool') throw new Error('expected tool') + return tool.data.displayTitle + } + + it('rewrites query_logs to past tense on success', () => { + expect(firstToolTitle([queryLogsCall('success')])).toBe('Queried logs') + }) + + it('preserves the enriched workflow name in the past-tense title', () => { + expect(firstToolTitle([queryLogsCall('success', 'Querying logs for Invoice Bot')])).toBe( + 'Queried logs for Invoice Bot' + ) + }) + + it('keeps present tense while executing and on error', () => { + expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') + expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') + }) +}) + +describe('narration text seams', () => { + it('inserts a space between glued consecutive blocks', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { + type: 'subagent_thinking', + content: 'that triggered it.', + spanId: 'S1', + subagent: 'research', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'The failing block is X.', + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + expect(text.content).toBe('that triggered it. The failing block is X.') + }) + + it('never inserts a space into a segment split mid-word or mid-URL', () => { + const seam = (first: string, second: string): string => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { type: 'subagent_text', content: first, spanId: 'S1', subagent: 'research', timestamp: 2 }, + { + type: 'subagent_text', + content: second, + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + return text.content + } + + expect(seam('the fox jum', 'ps over')).toBe('the fox jumps over') + expect(seam('see https://example', '/path for details')).toBe( + 'see https://example/path for details' + ) + expect(seam('日本語のテキストが分割', 'されても壊れない')).toBe( + '日本語のテキストが分割されても壊れない' + ) + expect(seam('released in v2.', '1 last week')).toBe('released in v2.1 last week') + expect(seam('pi is 3.', '14 roughly')).toBe('pi is 3.14 roughly') + }) + + it('does not double-space when the seam already has whitespace', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { + type: 'subagent_text', + content: 'first sentence. ', + spanId: 'S1', + subagent: 'research', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'second sentence.', + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + expect(text.content).toBe('first sentence. second sentence.') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index e4286bf5e22..dadd78aab6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -5,7 +5,11 @@ import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-ca import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { getToolDisplayTitle, humanizeToolName } from '@/lib/copilot/tools/tool-display' +import { + getToolCompletedTitle, + getToolDisplayTitle, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { ContentBlock, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS } from '../../types' @@ -101,8 +105,12 @@ function getOverrideDisplayTitle(tc: NonNullable): str function toToolData(tc: NonNullable): ToolCallData { const overrideDisplayTitle = getOverrideDisplayTitle(tc) - const displayTitle = + const resolvedTitle = overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) + const displayTitle = + tc.status === 'success' + ? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle) + : resolvedTitle return { id: tc.id, @@ -129,13 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { } } -function appendTextItem(group: AgentGroupSegment, content: string): void { +type NarrationChannel = 'thinking' | 'assistant' + +/** + * Appends narration content to a group, merging into the previous text item. + * When a thinking run and a text run meet, their contents can glue together + * without any whitespace at the seam. The merge repairs only that semantic + * channel transition, and only at an unambiguous sentence boundary — trailing + * punctuation meeting a fresh alphanumeric start. Same-channel continuations + * (streamed chunks of one run, resume legs) are concatenated verbatim, so a + * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the + * caller's per-parse tracker of each group's most recent narration channel. + */ +function appendTextItem( + group: AgentGroupSegment, + content: string, + channel: NarrationChannel, + lastChannelByGroup: Map +): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { - lastItem.content += content + const isChannelSeam = lastChannelByGroup.get(group) !== channel + const needsSpace = + isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content) + lastItem.content += (needsSpace ? ' ' : '') + content } else { group.items.push({ type: 'text', content }) } + lastChannelByGroup.set(group, channel) } /** @@ -149,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void { function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() + const lastNarrationChannel = new Map() // Stable per-run counters for React keys. The Nth top-level text run / Nth // mothership group keeps the same key across re-parses (text runs and groups // are append-only at the top level), so React never remounts the streaming @@ -255,7 +285,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { } if (!g) continue g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) continue } @@ -271,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) if (g) { g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } @@ -400,6 +435,7 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() + const lastNarrationChannel = new Map() let activeGroupKey: string | null = null const groupKey = (name: string, parentToolCallId: string | undefined) => @@ -471,12 +507,12 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = findGroupForSubagentChunk(block.parentToolCallId) if (!g) continue g.isDelegating = false - const lastItem = g.items[g.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - g.items.push({ type: 'text', content: block.content }) - } + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) continue } @@ -494,12 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId)) if (g) { g.isDelegating = false - const lastItem = g.items[g.items.length - 1] - if (lastItem?.type === 'text') { - lastItem.content += block.content - } else { - g.items.push({ type: 'text', content: block.content }) - } + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 6a06125c07a..767575849b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -71,11 +71,6 @@ export function getAgentIcon(name: string): IconComponent { return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp } -export function getToolIcon(name: string): IconComponent | undefined { - const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS] - return icon === Blimp ? undefined : icon -} - export type MessagePhase = 'streaming' | 'revealing' | 'settled' interface DeriveMessagePhaseArgs { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 8da0b7358e8..26c2aa0b3a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -14,7 +14,7 @@ import { } from '@sim/emcn' import Link from 'next/link' import { useParams } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { blockTypeToIconMap, formatIntegrationType, @@ -35,9 +35,7 @@ import { integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' - -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const @@ -145,19 +143,12 @@ export function Integrations() { /** * 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. + * write is debounced. The raw value is written (trimming happens on read in + * the filters below) so trailing spaces stay typable mid-word. Filtering 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 setSearchTerm = useDebouncedSearchSetter((value, options) => + setIntegrationFilters({ search: value }, options) ) const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 33d2e17d9b2..6a875d4a824 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -34,6 +34,7 @@ import { DocumentTagsModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { + documentChunkSortParams, documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' @@ -51,10 +52,19 @@ import { useUpdateDocument, } from '@/hooks/queries/kb/knowledge' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('Document') +/** + * Debounce window for chunk-search URL writes and the query feed; the input + * itself stays instant. Intentionally shorter than the shared + * `SEARCH_DEBOUNCE_MS` (300) to match the chunk search's snappier feel. + */ +const CHUNK_SEARCH_DEBOUNCE_MS = 200 as const + type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' interface UnsavedChangesModalProps { @@ -127,10 +137,10 @@ export function Document({ }: DocumentProps) { const { workspaceId } = useParams() const router = useRouter() - const [{ page: currentPageFromURL, chunk: chunkFromURL }, setDocumentParams] = useQueryStates( - documentParsers, - documentUrlKeys - ) + const [ + { page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery }, + setDocumentParams, + ] = useQueryStates(documentParsers, documentUrlKeys) const userPermissions = useUserPermissionsContext() const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) @@ -138,13 +148,25 @@ export function Document({ const [showTagsModal, setShowTagsModal] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const debouncedSearchQuery = useDebounce(searchQuery, 200) + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The chunk search query below reads a debounced value so + * it doesn't refetch on every keystroke. Changing the search resets `page` in + * the same write — a search started from a later page must land on the first + * page of matches, and a shared search link must open there too. + */ + const handleSearchChange = useDebouncedSearchSetter( + (value, options) => void setDocumentParams({ search: value, page: null }, options), + { debounceMs: CHUNK_SEARCH_DEBOUNCE_MS } + ) + /** Raw URL value drives the input; the chunk search query always sees it trimmed. */ + const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim() const [enabledFilter, setEnabledFilter] = useState([]) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) + const { + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(documentChunkSortParams, documentUrlKeys) const enabledFilterParam = useMemo( () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), @@ -181,7 +203,7 @@ export function Document({ search: debouncedSearchQuery, }, { - enabled: Boolean(debouncedSearchQuery.trim()), + enabled: Boolean(debouncedSearchQuery), } ) @@ -211,7 +233,7 @@ export function Document({ const saveStatusRef = useRef('idle') saveStatusRef.current = saveStatus - const isSearching = debouncedSearchQuery.trim().length > 0 + const isSearching = debouncedSearchQuery.length > 0 const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0 const SEARCH_PAGE_SIZE = 50 const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE) @@ -591,7 +613,7 @@ export function Document({ const searchConfig: SearchConfig | undefined = isCompleted ? { value: searchQuery, - onChange: (value: string) => setSearchQuery(value), + onChange: handleSearchChange, placeholder: 'Search chunks...', } : undefined @@ -861,16 +883,17 @@ export function Document({ { id: 'status', label: 'Status' }, ], active: activeSort, + /** Sorting (or clearing the sort) resets pagination to the first page. */ onSort: (column, direction) => { - setActiveSort({ column, direction }) + onSortColumn(column, direction) void goToPage(1) }, onClear: () => { - setActiveSort(null) + onClearSort() void goToPage(1) }, }), - [activeSort, goToPage] + [activeSort, onSortColumn, onClearSort, goToPage] ) const chunkRows: ResourceRow[] = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts index a2a7fb89346..072e08d66ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -1,4 +1,16 @@ import { parseAsInteger, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' + +/** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */ +export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const + +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`), in + * nullable mode: with no active sort the chunk query omits `sortBy` entirely + * (server default order), which is distinct from any explicit column, so an + * explicit selection always persists in the URL and clearing strips both. + */ +export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS) /** * Co-located, typed URL query-param definitions for the knowledge document @@ -9,10 +21,14 @@ import { parseAsInteger, parseAsString } from 'nuqs/server' * 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. + * - `search` is the chunk content search. The input is controlled directly by + * the instant nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const documentParsers = { page: parseAsInteger.withDefault(1), chunk: parseAsString, + search: parseAsString.withDefault(''), } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 2d6863b3250..205de1bd764 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -29,7 +29,7 @@ import { generateId } from '@sim/utils/id' import { format } from 'date-fns' import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryState, useQueryStates } from 'nuqs' +import { useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { SearchHighlight } from '@/components/ui/search-highlight' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' @@ -38,6 +38,7 @@ import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/ import type { DocumentData } from '@/lib/knowledge/types' import { captureEvent } from '@/lib/posthog/client' import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { BreadcrumbItem, FilterTag, @@ -61,11 +62,9 @@ import { } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { addConnectorParam, - DEFAULT_KB_SORT_COLUMN, - DEFAULT_KB_SORT_DIRECTION, documentFiltersParsers, documentFiltersUrlKeys, - type KbSortColumn, + kbDocumentSortParams, pageParam, pageUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' @@ -92,8 +91,10 @@ import { useUpdateKnowledgeBase, } from '@/hooks/queries/kb/knowledge' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('KnowledgeBase') @@ -296,41 +297,31 @@ export function KnowledgeBase({ ...pageUrlKeys, }) - const [ - { q: searchQuery, enabled: enabledFilter, sort: sortColumn, dir: sortDirection }, - setDocumentFilters, - ] = useQueryStates(documentFiltersParsers, documentFiltersUrlKeys) + const [{ q: searchQuery, enabled: enabledFilter }, 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) + const handleSearchChange = useDebouncedSearchSetter((value, options) => { + setDocumentFilters({ q: value }, options) + setCurrentPage(1) + }) + const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) + /** Raw URL value drives the input; matching/highlighting always sees it trimmed. */ + const highlightQuery = searchQuery.trim() - /** - * 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 { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(kbDocumentSortParams, documentFiltersUrlKeys) const setEnabledFilter = useCallback( (value: 'all' | 'enabled' | 'disabled') => { @@ -385,7 +376,7 @@ export function KnowledgeBase({ updateDocument, refreshDocuments, } = useKnowledgeBaseDocuments(id, { - search: debouncedSearchQuery || undefined, + search: debouncedSearchQuery.trim() || undefined, limit: DOCUMENTS_PER_PAGE, offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, sortBy: sortColumn as DocumentSortField, @@ -914,20 +905,17 @@ export function KnowledgeBase({ { id: 'enabled', label: 'Status' }, ], active: activeSort, + /** Sorting (or clearing the sort) resets pagination to the first page. */ onSort: (column, direction) => { - setDocumentFilters({ sort: column as KbSortColumn, dir: direction }) + onSortColumn(column, 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: () => { - setDocumentFilters({ sort: DEFAULT_KB_SORT_COLUMN, dir: DEFAULT_KB_SORT_DIRECTION }) + onClearSort() setCurrentPage(1) }, }), - [activeSort, setDocumentFilters, setCurrentPage] + [activeSort, onSortColumn, onClearSort, setCurrentPage] ) const filterContent = useMemo( @@ -1130,7 +1118,7 @@ export function KnowledgeBase({ label={doc.filename} className={cn('block', chipContentLabelClass)} > - + ), @@ -1166,7 +1154,7 @@ export function KnowledgeBase({ }, } }), - [documents, tagDefinitions, searchQuery] + [documents, tagDefinitions, highlightQuery] ) if (error && !knowledgeBase) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts index 39a57eddc86..c7f1ae8f27e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -1,5 +1,6 @@ import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' +import { createSortParams } from '@/lib/url-state' /** * Co-located, typed URL query-param definitions for the knowledge base detail @@ -44,24 +45,23 @@ export const KB_SORT_COLUMNS = [ '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' +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The + * default (most-recently-uploaded first) matches the document query's default + * order, so a clean URL means the default sort. + */ +export const kbDocumentSortParams = createSortParams(KB_SORT_COLUMNS, { + column: 'uploadedAt', + direction: 'desc', +}) /** - * Grouped filter/search/sort URL state for the document list. + * Grouped filter/search 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. + * instant nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — 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 @@ -71,8 +71,6 @@ export const DEFAULT_KB_SORT_DIRECTION = 'desc' 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. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index be7b5ccd801..d89e8683fc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -6,7 +6,9 @@ import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn' import { Database } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' import type { KnowledgeBaseData } from '@/lib/knowledge/types' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -30,6 +32,11 @@ import { KnowledgeBaseContextMenu, KnowledgeListContextMenu, } from '@/app/workspace/[workspaceId]/knowledge/components' +import { + knowledgeParsers, + knowledgeSortParams, + knowledgeUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/search-params' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -38,7 +45,9 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('Knowledge') @@ -143,16 +152,44 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) - const [connectorFilter, setConnectorFilter] = useState([]) - const [contentFilter, setContentFilter] = useState([]) - const [ownerFilter, setOwnerFilter] = useState([]) + const [ + { + search: urlSearchQuery, + connector: connectorFilter, + content: contentFilter, + owner: ownerFilter, + }, + setKnowledgeFilters, + ] = useQueryStates(knowledgeParsers, knowledgeUrlKeys) + + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. The in-memory filter below still reads a debounced + * value so it doesn't recompute on every keystroke. + */ + const setSearchQuery = useDebouncedSearchSetter((value, options) => + setKnowledgeFilters({ search: value }, options) + ) + const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) - const [searchInputValue, setSearchInputValue] = useState('') - const debouncedSearchQuery = useDebounce(searchInputValue, 300) + const { + activeSort, + onSort: onSortColumn, + onClear: onClearSort, + } = useUrlSort(knowledgeSortParams, knowledgeUrlKeys) + + const setConnectorFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ connector: next }), + [setKnowledgeFilters] + ) + const setContentFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ content: next }), + [setKnowledgeFilters] + ) + const setOwnerFilter = useCallback( + (next: string[]) => setKnowledgeFilters({ owner: next }), + [setKnowledgeFilters] + ) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) @@ -402,12 +439,12 @@ export function Knowledge() { const searchConfig: SearchConfig = useMemo( () => ({ - value: searchInputValue, - onChange: setSearchInputValue, - onClearAll: () => setSearchInputValue(''), + value: urlSearchQuery, + onChange: setSearchQuery, + onClearAll: () => setSearchQuery(''), placeholder: 'Search knowledge bases...', }), - [searchInputValue] + [urlSearchQuery, setSearchQuery] ) const sortConfig: SortConfig = useMemo( @@ -422,10 +459,10 @@ export function Knowledge() { { id: 'owner', label: 'Owner' }, ], active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + onSort: onSortColumn, + onClear: onClearSort, }), - [activeSort] + [activeSort, onSortColumn, onClearSort] ) const memberOptions: ChipDropdownOption[] = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 6243dc42035..402d437b4f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { Knowledge } from './knowledge' @@ -8,6 +10,12 @@ export const metadata: Metadata = { title: 'Knowledge Base', } +/** + * Knowledge Base page entry. `Knowledge` reads URL query params via nuqs (which + * uses `useSearchParams` internally), so it must sit under a Suspense boundary. + * The fallback renders the real chrome so a suspend never shows a blank frame; + * the route-level `loading.tsx` covers the navigation/chunk-load transition. + */ export default async function KnowledgePage({ params, }: { @@ -20,7 +28,9 @@ export default async function KnowledgePage({ return ( - + }> + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts new file mode 100644 index 00000000000..8a10c0796d0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -0,0 +1,51 @@ +import { parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' + +/** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */ +export const KNOWLEDGE_SORT_COLUMNS = [ + 'name', + 'documents', + 'tokens', + 'connectors', + 'created', + 'owner', + 'updated', +] as const + +/** + * `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The + * default (most-recently-updated first) matches the list's default ordering, + * so a clean URL means the default sort. + */ +export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) + +/** + * Co-located, typed URL query-param definitions for the Knowledge Base list. + * + * - `search` is the knowledge base name/description filter. The input is + * controlled directly by the instant nuqs value; only its URL write is + * debounced via `limitUrlUpdates` (`debounce`) on the setter — never written + * on every keystroke. + * - `connector` filters by connector presence; `content` filters by document + * presence; `owner` filters by creator id. All are multi-select arrays. + * + * Selecting a knowledge base navigates to the `knowledge/[id]` route (via + * `router`), so the active knowledge base is route state, not query state, and + * is intentionally not represented here. + */ +export const knowledgeParsers = { + search: parseAsString.withDefault(''), + connector: parseAsArrayOf(parseAsString).withDefault([]), + content: parseAsArrayOf(parseAsString).withDefault([]), + owner: parseAsArrayOf(parseAsString).withDefault([]), +} as const + +/** Filter/search/sort view-state: clean URLs, no back-stack churn. */ +export const knowledgeUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts index 76bc770e972..5c5df5059aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts @@ -48,7 +48,7 @@ export function filterKnowledgeBases( return knowledgeBases } - const query = searchQuery.toLowerCase() + const query = searchQuery.trim().toLowerCase() return knowledgeBases.filter( (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts index f8cf3ad8fa6..b3a228ac373 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts @@ -1,18 +1,16 @@ 'use client' import { useCallback, useMemo } from 'react' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { logFilterParsers, logFilterUrlKeys, } from '@/app/workspace/[workspaceId]/logs/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' 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. * @@ -118,18 +116,11 @@ export function useLogFilters(): UseLogFilters { /** * 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. + * Writes the raw value (query consumers trim on read); 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 setSearchQuery = useDebouncedSearchSetter((value, options) => + setFilters({ search: value }, options) ) const setTriggers = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index cfdeeaad72d..9274a26e23a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -46,6 +46,7 @@ import { type TriggerData, type WorkflowData, } from '@/lib/logs/search-suggestions' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -59,14 +60,16 @@ import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log- import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { executionIdParam, + executionIdWriteOptions, logDetailsTabParam, logDetailsTabUrlKeys, + logFilterUrlKeys, + logSortParams, } 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' import { useFolderMap, useFolders } from '@/hooks/queries/folders' -import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' import { fetchLogDetail, logKeys, @@ -80,6 +83,7 @@ import { } from '@/hooks/queries/logs' import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' +import { useUrlSort } from '@/hooks/use-url-sort' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' @@ -97,7 +101,6 @@ import { } from './utils' const LOGS_PER_PAGE = 50 as const -const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const const REFRESH_SPINNER_DURATION_MS = 1000 as const const LIVE_REFRESH_INTERVAL_MS = 10_000 as const const ACTIVE_RUN_DETAIL_REFRESH_MS = 3_000 as const @@ -237,7 +240,7 @@ export default function Logs() { isSidebarOpen: false, }) - const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser) + const [executionId, setExecutionId] = useQueryState(executionIdParam.key, executionIdParam.parser) const [pendingExecutionId, setPendingExecutionId] = useState(() => executionId) /** @@ -253,9 +256,10 @@ export default function Logs() { /** * `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. + * per-keystroke fetches. The raw value is written to the URL, so trim here on + * read — the server keeps receiving a trimmed query. */ - const debouncedSearchQuery = useDebounce(urlSearchQuery, 300) + const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS).trim() const isLive = true const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) @@ -264,16 +268,26 @@ export default function Logs() { const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) + const isSidebarOpenRef = useRef(false) const shouldScrollIntoViewRef = useRef(false) const resourceTableRef = useRef(null) const logsRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) + + /** + * URL-backed sort (`sort` + `dir`). The defaults match the server's default + * ordering, so a clean URL means "no active sort" and clearing the sort + * writes the defaults back (which `clearOnDefault` strips from the URL). + */ + const { + sort: sortBy, + dir: sortOrder, + activeSort, + onSort, + onClear: onClearSort, + } = useUrlSort(logSortParams, logFilterUrlKeys) const userPermissions = useUserPermissionsContext() const [contextMenuOpen, setContextMenuOpen] = useState(false) @@ -301,12 +315,6 @@ export default function Logs() { refetchInterval, }) - const sortBy: LogSortBy = - activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy) - ? (activeSort.column as LogSortBy) - : 'date' - const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc' - const logFilters = useMemo( () => ({ timeRange, @@ -377,6 +385,7 @@ export default function Logs() { logsRef.current = logs selectedLogIndexRef.current = selectedLogIndex selectedLogIdRef.current = selectedLogId + isSidebarOpenRef.current = isSidebarOpen logsRefetchRef.current = logsQuery.refetch activeLogRefetchRef.current = selectedDetailQuery.refetch logsQueryRef.current = { @@ -406,31 +415,69 @@ export default function Logs() { } }, []) - const handleLogClick = useCallback((rowId: string) => { - dispatch({ type: 'TOGGLE_LOG', logId: rowId }) - }, []) + /** + * The single write path for user-driven `executionId` changes. Cancels any + * in-flight deep-link resolution first — an explicit interaction supersedes + * it, otherwise the resolved row would open over the user's selection and + * leave the URL pointing at a different run than the panel shows. + */ + const writeExecutionId = useCallback( + (value: string | null) => { + setPendingExecutionId(null) + void setExecutionId(value, executionIdWriteOptions) + }, + [setExecutionId] + ) + + /** + * Mirrors the reducer's TOGGLE_LOG branch: clicking the already-open row + * closes the sidebar (strip `executionId`); any other click opens the row + * (sync `executionId` to it so the URL always deep-links the open run). + */ + const handleLogClick = useCallback( + (rowId: string) => { + const opens = !(selectedLogIdRef.current === rowId && isSidebarOpenRef.current) + dispatch({ type: 'TOGGLE_LOG', logId: rowId }) + if (opens) { + const log = logsRef.current.find((l) => l.id === rowId) + writeExecutionId(log?.executionId ?? null) + } else { + writeExecutionId(null) + } + }, + [writeExecutionId] + ) const handleNavigateNext = useCallback(() => { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current if (idx >= 0 && idx < currentLogs.length - 1) { + const nextLog = currentLogs[idx + 1] shouldScrollIntoViewRef.current = true - dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) + dispatch({ type: 'SELECT_LOG', logId: nextLog.id }) + if (isSidebarOpenRef.current) { + writeExecutionId(nextLog.executionId ?? null) + } } - }, []) + }, [writeExecutionId]) const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current if (idx > 0) { + const prevLog = logsRef.current[idx - 1] shouldScrollIntoViewRef.current = true - dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) + dispatch({ type: 'SELECT_LOG', logId: prevLog.id }) + if (isSidebarOpenRef.current) { + writeExecutionId(prevLog.executionId ?? null) + } } - }, []) + }, [writeExecutionId]) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) + writeExecutionId(null) activeLogTabRef.current = 'overview' - }, []) + }, [writeExecutionId]) /** * Strip the `tab` param whenever the detail panel transitions from open to @@ -659,6 +706,9 @@ export default function Logs() { const handleNavigateNextEvent = useEffectEvent(handleNavigateNext) const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev) + const writeExecutionIdEvent = useEffectEvent((value: string | null) => { + writeExecutionId(value) + }) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -693,7 +743,14 @@ export default function Logs() { if (e.key === 'Enter' && selectedLogIdRef.current) { e.preventDefault() + const willOpen = !isSidebarOpenRef.current dispatch({ type: 'TOGGLE_SIDEBAR' }) + if (willOpen) { + const log = currentLogs.find((l) => l.id === selectedLogIdRef.current) + writeExecutionIdEvent(log?.executionId ?? null) + } else { + writeExecutionIdEvent(null) + } } } @@ -1017,10 +1074,10 @@ export default function Logs() { { id: 'status', label: 'Status' }, ], active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), + onSort, + onClear: onClearSort, }), - [activeSort] + [activeSort, onSort, onClearSort] ) const searchConfig = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts index 643e9f992cc..d2171915c21 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -1,4 +1,6 @@ import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' +import type { LogSortBy } from '@/hooks/queries/logs' import { CORE_TRIGGER_TYPES, type LogLevel, @@ -12,8 +14,9 @@ import { * 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. + * shipped before nuqs: `timeRange` uses kebab tokens and `level` / + * `workflowIds` / `folderIds` / `triggers` are comma-joined. `search` carries + * the raw input value (consumers trim on read). */ const DEFAULT_TIME_RANGE: TimeRange = 'All time' @@ -116,16 +119,45 @@ export const logFilterUrlKeys = { clearOnDefault: true, } as const +/** Columns the logs list can sort by; must stay in sync with {@link LogSortBy}. */ +export const LOG_SORT_COLUMNS = [ + 'date', + 'duration', + 'cost', + 'status', +] as const satisfies readonly LogSortBy[] + +/** + * Sort params for the logs resource table (`sort` + `dir`). The defaults match + * the server's default ordering exactly (newest first), so with + * `clearOnDefault` a clean URL means "no active sort" and clearing the sort + * strips both params. Shares {@link logFilterUrlKeys} so sort changes replace + * history like filter changes. + */ +export const logSortParams = createSortParams(LOG_SORT_COLUMNS, { + column: 'date', + direction: 'desc', +}) + /** - * 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. + * Deep link to a specific execution. On load it resolves to a log row and + * opens the details sidebar; from then on it is kept in sync with the open + * run — row click, Enter, the sidebar's next/prev navigation, and closing the + * panel all write it — so the address bar always matches the open log and the + * link stays shareable. */ export const executionIdParam = { key: 'executionId', parser: parseAsString, } as const +/** + * Options for every `executionId` write. Browsing runs is view-state, not a + * destination — `replace` keeps rapid row-to-row navigation from flooding the + * browser back stack. + */ +export const executionIdWriteOptions = { history: 'replace' } as const + const LOG_DETAILS_TABS = ['overview', 'trace'] as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts index 88f03fcdc9e..60a18f7572c 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts @@ -1,13 +1,18 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { truncate } from '@sim/utils/string' +import { useQueryState } from 'nuqs' import type { CreateScheduleBody, UpdateScheduleBody } from '@/lib/api/contracts/schedules' import { zonedWallClock } from '@/lib/core/utils/timezone' import type { TaskDraft, TaskEditSeed, } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal' +import { + taskIdParam, + taskIdUrlKeys, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params' import { cronToRecurrence, recurrenceToScheduleFields, @@ -110,8 +115,10 @@ export interface UseScheduledTasksReturn { /** * Bridges the calendar to the persisted job-schedule backend: reads the * workspace's scheduled tasks, expands them into the occurrences visible in the - * current range, and exposes create/edit/delete mutations. UI-only selection - * state lives here; all task data flows through React Query. + * current range, and exposes create/edit/delete mutations. The open task lives + * in the URL (`?taskId=`, deep-linkable — see {@link taskIdParam}) and the task + * object is derived from the loaded occurrences; all task data flows through + * React Query. */ export function useScheduledTasks({ workspaceId, @@ -126,7 +133,10 @@ export function useScheduledTasks({ const disableSchedule = useDisableSchedule() const resumeSchedule = useResumeSchedule() - const [selectedTask, setSelectedTask] = useState(null) + const [taskId, setTaskId] = useQueryState(taskIdParam.key, { + ...taskIdParam.parser, + ...taskIdUrlKeys, + }) const events = useMemo(() => { const now = new Date() @@ -139,8 +149,49 @@ export function useScheduledTasks({ const eventsByDay = useMemo(() => bucketEventsByDay(events), [events]) - const openTask = useCallback((task: ScheduledTask) => setSelectedTask(task), []) - const closeTask = useCallback(() => setSelectedTask(null), []) + /** + * Occurrence lookup for the `?taskId=` deep link. First occurrence wins on a + * duplicate id — a one-time schedule reuses its bare schedule id for both its + * pending run and its last-run marker (mutually exclusive today, but the id's + * meaning shifts as the run completes). Until schedules load — or when the + * occurrence falls outside the current anchor/scope window — the id doesn't + * resolve, `selectedTask` stays `null`, and the param lingers harmlessly; the + * modal opens as soon as the id resolves. Stability of an OPEN modal relies on + * the schedules query not refetching in the background (no refetchInterval; + * the app-wide QueryClient disables refetchOnWindowFocus) — a mid-edit refetch + * that regenerates occurrence ids would close the modal and drop the draft. + */ + const taskById = useMemo(() => { + const byId = new Map() + for (const event of events) { + if (!byId.has(event.task.id)) byId.set(event.task.id, event) + } + return byId + }, [events]) + + const selectedTask = taskId ? (taskById.get(taskId)?.task ?? null) : null + + const openTask = useCallback((task: ScheduledTask) => setTaskId(task.id), [setTaskId]) + /** Closing replaces the URL — Back should leave the calendar, not reopen the modal. */ + const closeTask = useCallback(() => setTaskId(null, { history: 'replace' }), [setTaskId]) + + /** + * Mutation-driven closes replace the URL instead of pushing — Back must not + * reopen a task the user just deleted/paused/resumed. Matches by schedule id + * prefix because occurrence ids are `scheduleId`, `scheduleId:`, + * or `scheduleId:last` (the ISO contains colons, so never split on `:`). + */ + const clearTaskIdForSchedule = useCallback( + (scheduleId: string) => + setTaskId( + (current) => + current !== null && (current === scheduleId || current.startsWith(`${scheduleId}:`)) + ? null + : current, + { history: 'replace' } + ), + [setTaskId] + ) const editSeedFor = useCallback( (task: ScheduledTask): TaskEditSeed | null => { @@ -185,7 +236,7 @@ export function useScheduledTasks({ const deleteTask = useCallback( (scheduleId: string) => { deleteSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -194,7 +245,7 @@ export function useScheduledTasks({ const deleteOccurrence = useCallback( (scheduleId: string, occurrence: Date) => { excludeOccurrence.mutate({ scheduleId, workspaceId, occurrence: occurrence.toISOString() }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -203,7 +254,7 @@ export function useScheduledTasks({ const pauseTask = useCallback( (scheduleId: string) => { disableSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] @@ -212,7 +263,7 @@ export function useScheduledTasks({ const resumeTask = useCallback( (scheduleId: string) => { resumeSchedule.mutate({ scheduleId, workspaceId }) - setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current)) + clearTaskIdForSchedule(scheduleId) }, // eslint-disable-next-line react-hooks/exhaustive-deps [workspaceId] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts index ab611047485..602d0835b78 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts @@ -1,4 +1,4 @@ -import { createParser, parseAsStringLiteral } from 'nuqs/server' +import { createParser, parseAsString, parseAsStringLiteral } from 'nuqs/server' const CALENDAR_SCOPES = ['day', 'week', 'month'] as const @@ -59,3 +59,20 @@ export const calendarUrlKeys = { history: 'replace', clearOnDefault: true, } as const + +/** + * The open task occurrence's id (`?taskId=`). The value is the occurrence id + * from `scheduleToTasks`: `scheduleId` (one-time), `scheduleId:` + * (recurring occurrence), or `scheduleId:last` (last-run marker). Nullable — + * a clean URL means no task modal is open. + */ +export const taskIdParam = { + key: 'taskId', + parser: parseAsString, +} as const + +/** Opening a task is a destination — Back closes it. */ +export const taskIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index d494850bafb..d434f922ce2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -14,6 +14,7 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type ApiKey, type ApiKeyScope, @@ -105,7 +106,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const defaultKeyType = isPersonalScope ? 'personal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index 82a6be65d86..c746dd62828 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -18,6 +18,7 @@ import { Plus } from 'lucide-react' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type CopilotKey, useCopilotKeys, @@ -48,7 +49,7 @@ export function Copilot() { const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const [createError, setCreateError] = useState(null) const filteredKeys = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 7254477ac83..2a5e789da06 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -12,6 +12,7 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' @@ -26,7 +27,7 @@ export function CustomTools() { const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId) const deleteToolMutation = useDeleteCustomTool() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const [deletingTools, setDeletingTools] = useState>(() => new Set()) const [editingTool, setEditingTool] = useState(null) const [showAddForm, setShowAddForm] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 0200cdf45ab..5d00c3afa5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -5,7 +5,7 @@ import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn' import { formatRelativeTime } from '@sim/utils/formatting' import { ArrowRight, Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { type InboxStatusFilter, inboxTaskParsers, @@ -14,6 +14,7 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const STATUS_OPTIONS = [ { value: 'all', label: 'All statuses' }, @@ -26,9 +27,6 @@ const STATUS_OPTIONS = [ type StatusFilter = InboxStatusFilter -/** Debounce window for `search` URL writes; the input itself stays instant. */ -const SEARCH_DEBOUNCE_MS = 300 as const - const STATUS_BADGES: Record< string, { label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' } @@ -55,15 +53,8 @@ export function InboxTaskList() { * write is debounced. Filtering below is cheap in-memory over the loaded * tasks, so it reads the instant value too. */ - const setSearchTerm = useCallback( - (value: string) => { - const next = value.length > 0 ? value : null - setInboxFilters( - { search: next }, - next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } - ) - }, - [setInboxFilters] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setInboxFilters({ search: value }, options) ) const { data: config } = useInboxConfig(workspaceId) @@ -74,7 +65,7 @@ export function InboxTaskList() { const filteredTasks = useMemo(() => { if (!tasksData?.tasks) return [] if (!searchTerm.trim()) return tasksData.tasks - const term = searchTerm.toLowerCase() + const term = searchTerm.trim().toLowerCase() return tasksData.tasks.filter( (t) => t.subject?.toLowerCase().includes(term) || diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 6894bb87684..a7888d5f7dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -27,6 +27,7 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' import { type McpServer, @@ -187,7 +188,7 @@ export function MCP() { const [showAddModal, setShowAddModal] = useState(false) const [editingServerId, setEditingServerId] = useState(null) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const [deletingServers, setDeletingServers] = useState>(() => new Set()) const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({ workspaceId, @@ -261,8 +262,9 @@ export function MCP() { refetchStoredTools() } + /** Closing replaces the URL — Back should leave the section, not reopen the detail view. */ const handleBackToList = () => { - setSelectedServerId(null) + setSelectedServerId(null, { history: 'replace' }) setExpandedTools(new Set()) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 25f0c59e857..1e58cfe641a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -1,24 +1,21 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn' import { Folder, Search, Workflow } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' 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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { - DEFAULT_RECENTLY_DELETED_SORT_COLUMN, - DEFAULT_RECENTLY_DELETED_SORT_DIRECTION, - RECENTLY_DELETED_SORT_COLUMNS, - type RecentlyDeletedSortColumn, type RecentlyDeletedTab, recentlyDeletedParsers, + recentlyDeletedSortParams, recentlyDeletedUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' @@ -33,6 +30,8 @@ import { useWorkspaceFileFolders, } from '@/hooks/queries/workspace-file-folders' import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useUrlSort } from '@/hooks/use-url-sort' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' @@ -67,18 +66,6 @@ function getResourceHref( } } -type SortColumn = 'deleted' | 'name' | 'type' - -interface SortConfig { - column: SortColumn - direction: 'asc' | 'desc' -} - -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' }, @@ -159,35 +146,26 @@ export function RecentlyDeleted() { const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) - const [ - { tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm }, - setRecentlyDeletedFilters, - ] = useQueryStates(recentlyDeletedParsers, recentlyDeletedUrlKeys) + const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates( + recentlyDeletedParsers, + recentlyDeletedUrlKeys + ) + + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort, + onClear, + } = useUrlSort(recentlyDeletedSortParams, 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( - () => - sortColumn === DEFAULT_RECENTLY_DELETED_SORT_COLUMN && - sortDirection === DEFAULT_RECENTLY_DELETED_SORT_DIRECTION - ? null - : { column: sortColumn, direction: sortDirection }, - [sortColumn, sortDirection] + const setSearchTerm = useDebouncedSearchSetter((value, options) => + setRecentlyDeletedFilters({ search: value }, options) ) const [restoringIds, setRestoringIds] = useState>(new Set()) @@ -301,15 +279,13 @@ export function RecentlyDeleted() { const filtered = useMemo(() => { let items = resources.filter((resource) => matchesActiveTab(resource, activeTab)) - if (urlSearchTerm.trim()) { - const normalized = urlSearchTerm.toLowerCase() + const normalized = urlSearchTerm.trim().toLowerCase() + if (normalized) { items = items.filter((r) => r.name.toLowerCase().includes(normalized)) } - const col = (activeSort ?? DEFAULT_SORT).column - const dir = (activeSort ?? DEFAULT_SORT).direction items.sort((a, b) => { let cmp = 0 - switch (col) { + switch (sortColumn) { case 'name': cmp = a.name.localeCompare(b.name) break @@ -320,24 +296,21 @@ export function RecentlyDeleted() { cmp = a.deletedAt.getTime() - b.deletedAt.getTime() break } - return dir === 'asc' ? cmp : -cmp + return sortDirection === 'asc' ? cmp : -cmp }) const itemIds = new Set(items.map((item) => item.id)) for (const [id, entry] of restoredItems) { if (itemIds.has(id)) continue if (!matchesActiveTab(entry.resource, activeTab)) continue - if ( - urlSearchTerm.trim() && - !entry.resource.name.toLowerCase().includes(urlSearchTerm.toLowerCase()) - ) { + if (normalized && !entry.resource.name.toLowerCase().includes(normalized)) { continue } items.splice(Math.min(entry.displayIndex, items.length), 0, entry.resource) } return items - }, [resources, activeTab, urlSearchTerm, activeSort, restoredItems]) + }, [resources, activeTab, urlSearchTerm, sortColumn, sortDirection, restoredItems]) const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0 @@ -427,17 +400,8 @@ export function RecentlyDeleted() { config={{ options: SORT_OPTIONS, active: activeSort, - 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, - }), + onSort, + onClear, }} />
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index 01364745805..703359ce299 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,4 +1,5 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** Selectable resource-type tabs in the Recently Deleted view. */ export const RECENTLY_DELETED_TABS = [ @@ -15,30 +16,28 @@ 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' +/** + * Shared `sort` + `dir` params for the deleted-items list. Default sort: + * most-recently-deleted first. Consumed via `useUrlSort` in + * `recently-deleted.tsx`. + */ +export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_COLUMNS, { + column: 'deleted', + 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. + * - `sort` / `dir` live in {@link recentlyDeletedSortParams} (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. + * value; only its URL write is debounced via `useDebouncedSearchSetter`. */ 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 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts new file mode 100644 index 00000000000..8340051100e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -0,0 +1,23 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Shared URL query-param definition for the settings list search boxes + * (teammates, api-keys, copilot, custom-tools, mcp, secrets, + * workflow-mcp-servers). Settings sections never co-render, so they all share + * the `search` key without collisions. + * + * Consume via `useSettingsSearch` (`settings/components/use-settings-search`), + * which owns the debounced-write wiring — the input is controlled directly by + * the instant nuqs value; only the URL write is debounced. + */ +export const settingsSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search view-state: clean URLs, no back-stack churn. */ +export const settingsSearchUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 03481c6c7c6..3e18f61ff1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -20,6 +20,7 @@ import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/compone import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { @@ -362,7 +363,7 @@ export function SecretsManager() { const [newWorkspaceRows, setNewWorkspaceRows] = useState([ createEmptyEnvVar(), ]) - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) const [workspaceVars, setWorkspaceVars] = useState>({}) const [renamingKey, setRenamingKey] = useState(null) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts deleted file mode 100644 index 3366760fbc4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseAsString } from 'nuqs/server' - -/** - * Co-located, typed URL query-param definition for the Teammates view. - * - * `search` is the name/email filter. The input is controlled directly by the - * nuqs value; only its URL write is debounced via `limitUrlUpdates`. - */ -export const teammatesSearchParam = { - key: 'search', - parser: parseAsString.withDefault(''), -} as const - -/** Search view-state: clean URLs, no back-stack churn. */ -export const teammatesUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index db0b4b91738..170c5c9ec2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -6,7 +6,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryState } from 'nuqs' import { RoleLockTooltip, type WorkspaceRoleSource, @@ -22,10 +21,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { - teammatesSearchParam, - teammatesUrlKeys, -} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { useCancelWorkspaceInvitation, @@ -74,11 +70,7 @@ export function Teammates() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, { - ...teammatesSearchParam.parser, - ...teammatesUrlKeys, - limitUrlUpdates: debounce(300), - }) + const [searchTerm, setSearchTerm] = useSettingsSearch() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const { data: permissions, isPending: permissionsLoading } = @@ -177,7 +169,7 @@ export function Teammates() { void setSearchTerm(value), + onChange: setSearchTerm, placeholder: 'Search teammates...', }} actions={ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts new file mode 100644 index 00000000000..2da76cad0ee --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -0,0 +1,26 @@ +'use client' + +import { useQueryState } from 'nuqs' +import { + settingsSearchParam, + settingsSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +/** + * The shared `?search=` binding for settings list search boxes (teammates, + * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). + * Composes `useDebouncedSearchSetter`, so it carries the canonical semantics: + * the value updates instantly (drives the controlled input and the in-memory + * filter), non-empty URL writes are debounced, and clearing (or a + * whitespace-only value) strips the param immediately. The setter is + * `void`-returning so it passes straight to `SettingsPanel`'s `search.onChange`. + */ +export function useSettingsSearch(): [string, (value: string) => void] { + const [searchTerm, setSearchTermParam] = useQueryState(settingsSearchParam.key, { + ...settingsSearchParam.parser, + ...settingsSearchUrlKeys, + }) + const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) + return [searchTerm, setSearchTerm] +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 984f5cac0bf..b5c46b36462 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -24,15 +24,21 @@ import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' -import { useParams, useSearchParams } from 'next/navigation' +import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + mcpServerIdParam, + mcpServerIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' import { useCreateMcpServer } from '@/hooks/queries/mcp' @@ -871,7 +877,6 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe export function WorkflowMcpServers() { const params = useParams() const workspaceId = params.workspaceId as string - const searchParams = useSearchParams() const workspacePermissions = useUserPermissionsContext() const canAdmin = canMutateWorkspaceSettingsSection('workflow-mcp-servers', workspacePermissions) @@ -879,11 +884,12 @@ export function WorkflowMcpServers() { const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId) const deleteServerMutation = useDeleteWorkflowMcpServer() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useSettingsSearch() const [showAddModal, setShowAddModal] = useState(false) - const [selectedServerId, setSelectedServerId] = useState(() => - searchParams.get('mcpServerId') - ) + const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, { + ...mcpServerIdParam.parser, + ...mcpServerIdUrlKeys, + }) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(() => new Set()) @@ -925,13 +931,24 @@ export function WorkflowMcpServers() { const hasServers = servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers - if (selectedServerId) { + /** + * Render the detail view only while the id can still resolve — while the + * list loads (a fresh deep link) or when the server exists in it. A stale id + * (deleted server restored from an old history entry or a dead link) falls + * back to the list instead of a failed detail view; the lingering param is + * harmless and gets overwritten by the next selection. Closing replaces the + * URL so Back leaves the section rather than reopening the detail view. + */ + const selectedServerResolves = + selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId)) + + if (selectedServerId && selectedServerResolves) { return ( setSelectedServerId(null)} + onBack={() => setSelectedServerId(null, { history: 'replace' })} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index 4c7cf55fc45..ae63f33ebc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -19,3 +19,21 @@ export const skillIdUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * `search` filters the skills list by name/description. The input is controlled + * directly by the instant nuqs value; only its URL write is debounced via + * `limitUrlUpdates` (`debounce`) on the setter — never written on every + * keystroke. + */ +export const skillSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search is filter view-state: clean URLs, no back-stack churn. */ +export const skillSearchUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 182cc48af45..d045bf88b1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -11,8 +11,14 @@ import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal' -import { skillIdParam, skillIdUrlKeys } from '@/app/workspace/[workspaceId]/skills/search-params' +import { + skillIdParam, + skillIdUrlKeys, + skillSearchParam, + skillSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/skills/search-params' import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const logger = createLogger('SkillsSettings') @@ -67,7 +73,10 @@ export function Skills() { const { data: skills = [], isLoading, error } = useSkills(workspaceId) const deleteSkillMutation = useDeleteSkill() - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, { + ...skillSearchParam.parser, + ...skillSearchUrlKeys, + }) const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, { ...skillIdParam.parser, ...skillIdUrlKeys, @@ -76,12 +85,19 @@ export function Skills() { const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. Filtering below is cheap in-memory over a small list, + * so it reads the instant value too. + */ + const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) + /** Derive the skill being edited from the loaded list — never store the object in the URL. */ const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null const filteredSkills = skills.filter((s) => { if (!searchTerm.trim()) return true - const searchLower = searchTerm.toLowerCase() + const searchLower = searchTerm.trim().toLowerCase() return ( s.name.toLowerCase().includes(searchLower) || s.description.toLowerCase().includes(searchLower) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 58844116eec..cb2b2914ea9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -1,6 +1,5 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' - -const SORT_DIRECTIONS = ['asc', 'desc'] as const +import { SORT_DIRECTIONS } from '@/lib/url-state' /** Default sort direction applied when a sort column is selected. */ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts index 461dc62f005..a1c75373053 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/search-params.ts @@ -1,4 +1,5 @@ -import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsArrayOf, parseAsString } from 'nuqs/server' +import { createSortParams } from '@/lib/url-state' /** Sortable table columns, matching the `Resource.Options` sort menu. */ export const TABLE_SORT_COLUMNS = [ @@ -10,21 +11,21 @@ export const TABLE_SORT_COLUMNS = [ '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' +/** + * Shared `sort` + `dir` params for the Tables list. Default sort: + * most-recently-updated first. Consumed via `useUrlSort` in `tables.tsx`. + */ +export const tablesSortParams = createSortParams(TABLE_SORT_COLUMNS, { + column: 'updated', + 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). + * nuqs value; only its URL write is debounced via `useDebouncedSearchSetter`. + * - `sort` / `dir` live in {@link tablesSortParams} (shared sort convention). * - `rows` filters by row-count bucket; `owner` filters by creator id. Both are * multi-select arrays. * @@ -34,8 +35,6 @@ export const DEFAULT_TABLE_SORT_DIRECTION = 'desc' */ 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 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index c88ae332b2b..4cc8ff85baa 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -7,9 +7,10 @@ import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' -import { debounce, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { FilterTag, ResourceAction, @@ -27,11 +28,8 @@ import { } 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, + tablesSortParams, tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -47,15 +45,14 @@ import { } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useUrlSort } from '@/hooks/use-url-sort' 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' }, @@ -99,46 +96,26 @@ export function Tables() { const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) - const [ - { - search: urlSearchTerm, - sort: sortColumn, - dir: sortDirection, - rows: rowCountFilter, - owner: ownerFilter, - }, - setTableFilters, - ] = useQueryStates(tablesParsers, tablesUrlKeys) + const [{ search: urlSearchTerm, rows: rowCountFilter, owner: ownerFilter }, setTableFilters] = + useQueryStates(tablesParsers, tablesUrlKeys) + + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort, + onClear, + } = useUrlSort(tablesSortParams, 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 setSearchTerm = useDebouncedSearchSetter((value, options) => + setTableFilters({ search: value }, options) ) + const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) const setRowCountFilter = useCallback( (next: string[]) => setTableFilters({ rows: next }), @@ -168,9 +145,8 @@ export function Tables() { } = useContextMenu() const processedTables = useMemo(() => { - let result = debouncedSearchTerm - ? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - : tables + const query = debouncedSearchTerm.trim().toLowerCase() + let result = query ? tables.filter((t) => t.name.toLowerCase().includes(query)) : tables if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -183,11 +159,9 @@ export function Tables() { if (ownerFilter.length > 0) { result = result.filter((t) => ownerFilter.includes(t.createdBy)) } - const col = activeSort?.column ?? 'updated' - const dir = activeSort?.direction ?? 'desc' return [...result].sort((a, b) => { let cmp = 0 - switch (col) { + switch (sortColumn) { case 'name': cmp = a.name.localeCompare(b.name) break @@ -210,9 +184,9 @@ export function Tables() { break } } - return dir === 'asc' ? cmp : -cmp + return sortDirection === 'asc' ? cmp : -cmp }) - }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members]) + }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, sortColumn, sortDirection, members]) const rows: ResourceRow[] = useMemo( () => @@ -277,19 +251,10 @@ export function Tables() { { id: 'updated', label: 'Last Updated' }, ], active: activeSort, - 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, - }), + onSort, + onClear, }), - [activeSort, setTableFilters] + [activeSort, onSort, onClear] ) const rowCountDisplayLabel = useMemo(() => { diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 3b932062333..2cccdce5a10 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -81,10 +81,7 @@ export function EmailFooter({ - + + + + Slack + + diff --git a/apps/sim/components/ui/shimmer-text.module.css b/apps/sim/components/ui/shimmer-text.module.css index c4db09a974e..53637471b1a 100644 --- a/apps/sim/components/ui/shimmer-text.module.css +++ b/apps/sim/components/ui/shimmer-text.module.css @@ -13,7 +13,7 @@ -webkit-background-clip: text; background-clip: text; color: transparent; - animation: shimmer-sweep 2.2s linear infinite; + animation: shimmer-sweep 2.2s linear infinite reverse; } :global(.dark) .shimmer { diff --git a/apps/sim/content/blog/mothership/index.mdx b/apps/sim/content/blog/mothership/index.mdx index 8f9b0181972..1e978e5db87 100644 --- a/apps/sim/content/blog/mothership/index.mdx +++ b/apps/sim/content/blog/mothership/index.mdx @@ -137,4 +137,4 @@ That's what v0.6 is. Sim v0.6 is available now at [sim.ai](https://sim.ai). Check out our [documentation](https://docs.sim.ai) for detailed guides on Mothership, Tables, Connectors, and more. -*Questions? [help@sim.ai](mailto:help@sim.ai) · [Discord](https://sim.ai/discord)* +*Questions? [help@sim.ai](mailto:help@sim.ai) · [Slack](https://sim.ai/slack)* diff --git a/apps/sim/content/blog/v0-5/index.mdx b/apps/sim/content/blog/v0-5/index.mdx index 0e91f66959f..13604697eb3 100644 --- a/apps/sim/content/blog/v0-5/index.mdx +++ b/apps/sim/content/blog/v0-5/index.mdx @@ -201,4 +201,4 @@ Model selection is per-block, so you can use faster/cheaper models for simple ta Available now at [sim.ai](https://sim.ai). Check out the [docs](https://docs.sim.ai) to dive deeper. -*Questions? [help@sim.ai](mailto:help@sim.ai) · [Discord](https://sim.ai/discord)* +*Questions? [help@sim.ai](mailto:help@sim.ai) · [Slack](https://sim.ai/slack)* diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 1dc358bfeb2..567a2d3edc1 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -427,6 +427,9 @@ export class DAGExecutor { blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []), metadata: { ...this.contextExtensions.metadata, + ...(this.contextExtensions.billingAttribution + ? { billingAttribution: this.contextExtensions.billingAttribution } + : {}), startTime: new Date().toISOString(), duration: 0, useDraftState: diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index b93ad107c5c..891426646ad 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -168,6 +168,13 @@ export interface ContextExtensions { fileKeys?: string[] allowLargeValueWorkflowScope?: boolean userId?: string + /** + * Immutable actor/payer decision for this execution. Child workflow + * executions receive it here (they carry no full metadata), so internal + * tool calls inside the child still attach the billing attribution header. + * Takes precedence over `metadata.billingAttribution` when both are set. + */ + billingAttribution?: BillingAttributionSnapshot stream?: boolean selectedOutputs?: string[] edges?: Array<{ source: string; target: string }> diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index b650cc1059a..db21354265f 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -9,17 +9,43 @@ import { import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' -const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({ +const { + mockExecutorExecute, + mockCreateSnapshot, + mockResolveBillingAttribution, + mockGetCustomBlockAuthority, + mockGetPersonalAndWorkspaceEnv, + executorOptions, +} = vi.hoisted(() => ({ mockExecutorExecute: vi.fn(), mockCreateSnapshot: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockGetCustomBlockAuthority: vi.fn(), + mockGetPersonalAndWorkspaceEnv: vi.fn(), + executorOptions: [] as Array>, })) vi.mock('@/executor', () => ({ Executor: class { + constructor(options: Record) { + executorOptions.push(options) + } execute = mockExecutorExecute }, })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mockResolveBillingAttribution, +})) + +vi.mock('@/lib/environment/utils', () => ({ + getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv, +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + getCustomBlockAuthority: mockGetCustomBlockAuthority, +})) + vi.mock('@/lib/logs/execution/snapshot/service', () => ({ snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot }, })) @@ -92,6 +118,7 @@ describe('WorkflowBlockHandler', () => { // Reset all mocks vi.clearAllMocks() + executorOptions.length = 0 // Setup default fetch mock mockFetch.mockResolvedValue({ @@ -273,6 +300,107 @@ describe('WorkflowBlockHandler', () => { expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id') }) + it('threads the parent billing attribution into the child execution context', async () => { + const billingAttribution = { + actorUserId: 'actor-1', + workspaceId: 'workspace-parent', + organizationId: 'org-1', + billedAccountUserId: 'owner-1', + billingEntity: { type: 'organization', id: 'org-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + } + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + metadata: { ...mockContext.metadata, billingAttribution }, + } as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.billingAttribution).toBe(billingAttribution) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + }) + + it('resolves a source-scoped billing attribution for custom block children', async () => { + const consumerAttribution = { actorUserId: 'consumer-1', workspaceId: 'workspace-consumer' } + const sourceAttribution = { actorUserId: 'owner-9', workspaceId: 'workspace-source' } + const customBlock = { + ...mockBlock, + metadata: { id: 'custom_block_abc', name: 'Published Block' }, + } + const ctx = { + ...mockContext, + workspaceId: 'workspace-consumer', + metadata: { ...mockContext.metadata, billingAttribution: consumerAttribution }, + } as unknown as ExecutionContext + + mockGetCustomBlockAuthority.mockResolvedValue({ + workflowId: 'source-workflow-id', + organizationId: 'org-1', + ownerUserId: 'owner-9', + exposedOutputs: [], + requiredInputIds: [], + }) + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + personalDecrypted: {}, + workspaceDecrypted: {}, + }) + mockResolveBillingAttribution.mockResolvedValue(sourceAttribution) + mockFetch.mockImplementation(async (url: unknown) => { + if (String(url).includes('/deployed')) { + return { + ok: true, + json: () => + Promise.resolve({ + data: { + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + } + } + return { + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Source Workflow', + workspaceId: 'workspace-source', + variables: {}, + }, + }), + } + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, customBlock, {}) + + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'owner-9', + workspaceId: 'workspace-source', + }) + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution) + expect(executorOptions[0].contextExtensions.userId).toBe('owner-9') + expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source') + }) + it('should fail closed when the executing context has no workspace', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index edde6925eb5..7656e008044 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' @@ -360,6 +361,7 @@ export class WorkflowBlockHandler implements BlockHandler { let childUserId = ctx.userId let childWorkspaceId = ctx.workspaceId let childEnvVarValues = ctx.environmentVariables + let childBillingAttribution = ctx.metadata.billingAttribution if (isCustomBlock) { if (!loadUserId) { throw new Error('Custom block source workflow has no owner') @@ -371,6 +373,15 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkspaceId = childWorkflow.workspaceId const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId) childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted } + // Custom-block children authenticate internal tool calls as the source + // owner in the source workspace, so the consumer's snapshot would fail + // the internal routes' actor/workspace scope match. Resolve the + // source-scoped payer instead — the same decision those routes made + // themselves before attribution headers became required. + childBillingAttribution = await resolveBillingAttribution({ + actorUserId: loadUserId, + workspaceId: childWorkflow.workspaceId, + }) } const subExecutor = new Executor({ @@ -388,6 +399,10 @@ export class WorkflowBlockHandler implements BlockHandler { workspaceId: childWorkspaceId, userId: childUserId, executionId: ctx.executionId, + // Same-workspace children share the parent's frozen payer decision so + // internal tool calls (knowledge, guardrails, MCP, Mothership) can + // attach the required billing attribution header. + billingAttribution: childBillingAttribution, abortSignal: ctx.abortSignal, // Propagate in-flight block-output redaction into child workflows so // nested blocks mask outputs too (recurses: each child forwards it). diff --git a/apps/sim/hooks/use-debounced-search-setter.ts b/apps/sim/hooks/use-debounced-search-setter.ts new file mode 100644 index 00000000000..75d7af39668 --- /dev/null +++ b/apps/sim/hooks/use-debounced-search-setter.ts @@ -0,0 +1,38 @@ +'use client' + +import { useCallback, useRef } from 'react' +import { debounce, type Options } from 'nuqs' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' + +type SearchWrite = (value: string | null, options?: Options) => void + +interface UseDebouncedSearchSetterOptions { + debounceMs?: number +} + +/** + * The canonical setter for a nuqs-backed search param (see + * `.claude/rules/sim-url-state.md`, "Debounced text inputs"). The input stays + * controlled by the instant nuqs value; only the URL write is debounced. + * Clearing (or a whitespace-only value) writes `null` immediately so the param + * strips without lingering. The RAW value is written — never a trimmed one, + * which would eat the user's trailing space mid-typing; consumers trim on read. + * + * Grouped params: `useDebouncedSearchSetter((v, o) => setFilters({ search: v }, o))`. + * Single param: pass the `useQueryState` setter directly. + */ +export function useDebouncedSearchSetter( + write: SearchWrite, + { debounceMs = SEARCH_DEBOUNCE_MS }: UseDebouncedSearchSetterOptions = {} +): (value: string) => void { + const writeRef = useRef(write) + writeRef.current = write + + return useCallback( + (value: string) => { + const next = value.trim().length > 0 ? value : null + writeRef.current(next, next === null ? undefined : { limitUrlUpdates: debounce(debounceMs) }) + }, + [debounceMs] + ) +} diff --git a/apps/sim/hooks/use-url-sort.ts b/apps/sim/hooks/use-url-sort.ts new file mode 100644 index 00000000000..502c505e1b0 --- /dev/null +++ b/apps/sim/hooks/use-url-sort.ts @@ -0,0 +1,80 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { type Options, useQueryStates } from 'nuqs' +import type { DefaultedSortParams, NullableSortParams, SortDirection } from '@/lib/url-state' + +/** The nullable active-sort shape the shared sort menu (`SortConfig`) consumes. */ +export interface ActiveSort { + column: string + direction: SortDirection +} + +export interface UseUrlSortReturn { + /** Raw resolved column — feed this to query keys / comparators. */ + sort: Sort + /** Raw resolved direction. */ + dir: Dir + /** `null` when the list shows no active sort; plugs into `SortConfig.active`. */ + activeSort: ActiveSort | null + /** Validates the column against the param's literal set; no-op on unknown ids. */ + onSort: (column: string, direction: SortDirection) => void + /** Defaulted mode writes the defaults back (stripped by `clearOnDefault`); nullable mode strips both params. */ + onClear: () => void +} + +/** + * Binds a `createSortParams` definition (from `@/lib/url-state/sort-params`) + * to the URL and derives the canonical sort wiring for a sortable list: + * defaulted mode collapses an explicit default selection to "no active sort", + * nullable mode treats `null` params as the distinct unsorted state. Pass the + * feature's shared url-keys object (e.g. `{ history: 'replace', shallow: true, + * clearOnDefault: true }`) as `options`. + */ +export function useUrlSort( + params: DefaultedSortParams, + options?: Options +): UseUrlSortReturn +export function useUrlSort( + params: NullableSortParams, + options?: Options +): UseUrlSortReturn +export function useUrlSort( + params: DefaultedSortParams | NullableSortParams, + options: Options = {} +): UseUrlSortReturn { + const [values, setValues] = useQueryStates( + params.parsers as NullableSortParams['parsers'], + options + ) + const sort = values.sort ?? null + const dir = values.dir ?? null + const sortDefault = params.default + + const activeSort = useMemo(() => { + if (sortDefault !== null) { + return sort === sortDefault.column && dir === sortDefault.direction + ? null + : { column: sort as string, direction: dir as SortDirection } + } + return sort !== null && dir !== null ? { column: sort, direction: dir } : null + }, [sortDefault, sort, dir]) + + const onSort = useCallback( + (column: string, direction: SortDirection) => { + if (!(params.columns as readonly string[]).includes(column)) return + void setValues({ sort: column as C, dir: direction }) + }, + [params, setValues] + ) + + const onClear = useCallback(() => { + void setValues( + sortDefault !== null + ? { sort: sortDefault.column, dir: sortDefault.direction } + : { sort: null, dir: null } + ) + }, [sortDefault, setValues]) + + return { sort, dir, activeSort, onSort, onClear } +} diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index 9fccdec4892..d29bf4379fb 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -229,9 +229,21 @@ function buildDashboardOrganizationSummary({ export async function listDashboardUsers({ search, limit, offset }: PaginationInput) { const trimmed = search.trim() - const where = trimmed + // Mirror Better Auth's active-ban semantics: permanent bans and temporary + // bans whose expiry is still in the future stay out of the Users dashboard, + // while an expired temporary ban is treated as lifted. Keep this predicate + // in the database query so pagination totals cannot leak or count hidden rows. + const visibleUser = sql`NOT ( + coalesce(${user.banned}, false) + AND ( + ${user.banExpires} IS NULL + OR ${user.banExpires} > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + ) + )` + const searchMatch = trimmed ? or(ilike(user.name, `%${trimmed}%`), ilike(user.email, `%${trimmed}%`), eq(user.id, trimmed)) : undefined + const where = searchMatch ? and(visibleUser, searchMatch) : visibleUser const [totalRow, rows] = await Promise.all([ db.select({ total: count() }).from(user).where(where), db diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index 48038beeeef..ca06e5e6b13 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -29,7 +29,7 @@ export interface ContentRegistryConfig { export interface ContentRegistry { getAllPostMeta: () => Promise - getPostBySlug: (slug: string) => Promise + getPostBySlug: (slug: string) => Promise getAllTags: () => Promise getRelatedPosts: (slug: string, limit?: number) => Promise getNavPosts: () => Promise[]> @@ -225,10 +225,10 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg } } - async function getPostBySlug(slug: string): Promise { + async function getPostBySlug(slug: string): Promise { const meta = await scanFrontmatters() const found = meta.find((m) => m.slug === slug) - if (!found) throw new Error(`Post not found: ${slug}`) + if (!found) return null const mdxPath = path.join(contentDir, slug, 'index.mdx') const raw = await fs.readFile(mdxPath, 'utf-8') const { content, data } = matter(raw) diff --git a/apps/sim/lib/content/seo.ts b/apps/sim/lib/content/seo.ts index df224f91963..dcad1a7adf0 100644 --- a/apps/sim/lib/content/seo.ts +++ b/apps/sim/lib/content/seo.ts @@ -285,7 +285,7 @@ export function buildAuthorMetadata( author?: Author ): Metadata { const name = author?.name ?? 'Author' - const canonical = `${SITE_URL}${section.basePath}/authors/${id}` + const canonical = `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(id)}` const description = `Read articles by ${name} on the Sim ${section.name.toLowerCase()}.` return { title: `${name} | Sim ${section.name}`, @@ -318,9 +318,11 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) { '@type': 'Person', name: author.name, - url: `${SITE_URL}${section.basePath}/authors/${author.id}`, + url: `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(author.id)}`, sameAs: author.url ? [author.url] : [], - image: author.avatarUrl, + image: author.avatarUrl?.startsWith('http') + ? author.avatarUrl + : author.avatarUrl && `${SITE_URL}${author.avatarUrl}`, worksFor: { '@type': 'Organization', name: 'Sim', @@ -341,7 +343,7 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) '@type': 'ListItem', position: 3, name: author.name, - item: `${SITE_URL}${section.basePath}/authors/${author.id}`, + item: `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(author.id)}`, }, ], }, diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts new file mode 100644 index 00000000000..353750417a2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getToolCompletedTitle, + getToolDisplayTitle, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' + +describe('humanizeToolName', () => { + it('title-cases snake_case names', () => { + expect(humanizeToolName('manage_folder')).toBe('Manage Folder') + }) + + it('keeps canonical acronym casing', () => { + expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server') + expect(humanizeToolName('deploy_api')).toBe('Deploy API') + expect(humanizeToolName('oauth_request_access')).toBe('OAuth Request Access') + }) +}) + +describe('getToolDisplayTitle natural-language coverage', () => { + it('gives gerund titles to tools that previously fell through to humanize', () => { + expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') + expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') + expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') + expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') + }) + + it('falls back to running code for function_execute without a title', () => { + expect(getToolDisplayTitle('function_execute')).toBe('Running code') + expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( + 'Crunching numbers' + ) + }) +}) + +describe('getToolCompletedTitle', () => { + it('flips a leading gerund to past tense', () => { + expect(getToolCompletedTitle('Querying logs')).toBe('Queried logs') + expect(getToolCompletedTitle('Querying logs for Invoice Bot')).toBe( + 'Queried logs for Invoice Bot' + ) + expect(getToolCompletedTitle('Searching online for pricing')).toBe( + 'Searched online for pricing' + ) + expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') + expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') + expect(getToolCompletedTitle('Reading file')).toBe('Read file') + }) + + it('returns undefined for non-gerund titles', () => { + expect(getToolCompletedTitle('Run Agent')).toBeUndefined() + expect(getToolCompletedTitle('Folder action')).toBeUndefined() + expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5ee25a69959..099503901d1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -10,7 +10,7 @@ import { stripVersionSuffix } from '@sim/utils/string' * enrichment for the run_* tools; every other surface (server persistence, * transcript replay, fallback rendering) calls `getToolDisplayTitle` directly. * - * Icons are likewise client-owned — see `getToolIcon` in the message-content + * Icons are likewise client-owned — see `getAgentIcon` in the message-content * utils. Nothing about tool presentation lives on the Go side anymore. */ @@ -87,6 +87,57 @@ const TOOL_TITLES: Record = { generate_audio: 'Generating audio', ffmpeg: 'Processing media', manage_folder: 'Folder action', + check_deployment_status: 'Checking deployment status', + complete_scheduled_task: 'Completing scheduled task', + create_file: 'Creating file', + create_file_folder: 'Creating folder', + create_workspace_mcp_server: 'Creating MCP server', + delete_file: 'Deleting file', + delete_file_folder: 'Deleting folder', + delete_workflow: 'Deleting workflow', + delete_workspace_mcp_server: 'Deleting MCP server', + deploy_api: 'Deploying API', + deploy_chat: 'Deploying chat', + deploy_custom_block: 'Deploying custom block', + deploy_mcp: 'Deploying MCP server', + diff_workflows: 'Comparing workflows', + download_to_workspace_file: 'Downloading file', + function_execute: 'Running code', + generate_api_key: 'Generating API key', + get_block_outputs: 'Getting block outputs', + get_block_upstream_references: 'Getting block references', + get_deployed_workflow_state: 'Getting deployed workflow', + get_deployment_log: 'Getting deployment logs', + get_platform_actions: 'Getting platform actions', + get_scheduled_task_logs: 'Getting scheduled task logs', + get_workflow_data: 'Getting workflow data', + get_workflow_run_options: 'Getting run options', + list_file_folders: 'Listing folders', + list_integration_tools: 'Listing integrations', + list_user_workspaces: 'Listing workspaces', + list_workspace_mcp_servers: 'Listing MCP servers', + load_deployment: 'Loading deployment', + materialize_file: 'Preparing file', + move_file: 'Moving file', + move_file_folder: 'Moving folder', + move_workflow: 'Moving workflow', + oauth_get_auth_link: 'Getting authorization link', + oauth_request_access: 'Requesting access', + promote_to_live: 'Promoting to live', + redeploy: 'Redeploying', + rename_file: 'Renaming file', + rename_file_folder: 'Renaming folder', + rename_workflow: 'Renaming workflow', + restore_resource: 'Restoring resource', + run_block: 'Running block', + search_documentation: 'Searching documentation', + search_patterns: 'Searching patterns', + set_block_enabled: 'Toggling block', + set_environment_variables: 'Setting environment variables', + set_global_workflow_variables: 'Setting workflow variables', + update_deployment_version: 'Updating deployment', + update_scheduled_task_history: 'Updating task history', + update_workspace_mcp_server: 'Updating MCP server', // Subagent trigger tools, when surfaced as a tool call. workflow: 'Workflow Agent', run: 'Run Agent', @@ -101,6 +152,16 @@ const TOOL_TITLES: Record = { superagent: 'Executing action', } +/** Acronyms that must keep their canonical casing when humanized. */ +const ACRONYM_CASING: Record = { + mcp: 'MCP', + api: 'API', + oauth: 'OAuth', + url: 'URL', + id: 'ID', + ai: 'AI', +} + /** * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage * Folder"), matching the legacy client humanizer so labels never render blank. @@ -108,7 +169,9 @@ const TOOL_TITLES: Record = { export function humanizeToolName(name: string): string { const words = stripVersionSuffix(name).split('_').filter(Boolean) if (words.length === 0) return name - return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') + return words + .map((word) => ACRONYM_CASING[word] ?? word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') } /** @@ -221,3 +284,65 @@ export function getToolDisplayTitle(name: string, args?: Record return TOOL_TITLES[name] ?? humanizeToolName(name) } + +/** + * Present-participle to past-tense verb map for completed tool titles. Applied + * to the leading word only, so "Searching online for X" -> "Searched online + * for X" while non-gerund labels ("Run Agent", "Folder action") pass through. + */ +const COMPLETED_VERB_REWRITES: Record = { + Accessing: 'Accessed', + Adding: 'Added', + Applying: 'Applied', + Checking: 'Checked', + Comparing: 'Compared', + Completing: 'Completed', + Crawling: 'Crawled', + Creating: 'Created', + Deleting: 'Deleted', + Deploying: 'Deployed', + Downloading: 'Downloaded', + Editing: 'Edited', + Executing: 'Executed', + Finding: 'Found', + Gathering: 'Gathered', + Generating: 'Generated', + Getting: 'Got', + Listing: 'Listed', + Loading: 'Loaded', + Managing: 'Managed', + Moving: 'Moved', + Opening: 'Opened', + Preparing: 'Prepared', + Processing: 'Processed', + Promoting: 'Promoted', + Querying: 'Queried', + Reading: 'Read', + Redeploying: 'Redeployed', + Renaming: 'Renamed', + Requesting: 'Requested', + Restoring: 'Restored', + Running: 'Ran', + Scraping: 'Scraped', + Searching: 'Searched', + Setting: 'Set', + Toggling: 'Toggled', + Updating: 'Updated', + Validating: 'Validated', + Writing: 'Wrote', +} + +/** + * Rewrite a resolved display title to its past-tense form for a successfully + * completed tool call (e.g. "Querying logs for X" -> "Queried logs for X"). + * Operates on the already-resolved title so enriched and persisted titles both + * work. Returns undefined when the title has no leading gerund rewrite — the + * caller keeps the original. + */ +export function getToolCompletedTitle(title: string): string | undefined { + const spaceIndex = title.indexOf(' ') + const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) + const past = COMPLETED_VERB_REWRITES[firstWord] + if (!past) return undefined + return past + title.slice(firstWord.length) +} diff --git a/apps/sim/lib/url-state/constants.ts b/apps/sim/lib/url-state/constants.ts new file mode 100644 index 00000000000..f37b3f515cf --- /dev/null +++ b/apps/sim/lib/url-state/constants.ts @@ -0,0 +1,6 @@ +/** + * Shared debounce window for search-param URL writes across list surfaces. + * The input is always controlled by the instant nuqs value; only the URL + * write (and any query/filter consumer via `useDebounce`) waits this long. + */ +export const SEARCH_DEBOUNCE_MS = 300 as const diff --git a/apps/sim/lib/url-state/index.ts b/apps/sim/lib/url-state/index.ts new file mode 100644 index 00000000000..2559ea7a8dc --- /dev/null +++ b/apps/sim/lib/url-state/index.ts @@ -0,0 +1,9 @@ +export { SEARCH_DEBOUNCE_MS } from '@/lib/url-state/constants' +export { + createSortParams, + type DefaultedSortParams, + type NullableSortParams, + SORT_DIRECTIONS, + type SortDefault, + type SortDirection, +} from '@/lib/url-state/sort-params' diff --git a/apps/sim/lib/url-state/sort-params.ts b/apps/sim/lib/url-state/sort-params.ts new file mode 100644 index 00000000000..eff86afe6c7 --- /dev/null +++ b/apps/sim/lib/url-state/sort-params.ts @@ -0,0 +1,72 @@ +import { parseAsStringLiteral, type SingleParserBuilder } from 'nuqs/server' + +/** The two sort directions every sortable list shares. */ +export const SORT_DIRECTIONS = ['asc', 'desc'] as const + +export type SortDirection = (typeof SORT_DIRECTIONS)[number] + +/** The list's default ordering — what a clean URL means. */ +export interface SortDefault { + column: C + direction: SortDirection +} + +type DefaultedParser = ReturnType['withDefault']> + +/** + * Sort params whose defaults match the list's server/default ordering. A clean + * URL means the default sort; explicitly selecting the default collapses back + * to a clean URL (`clearOnDefault`), so "no active sort" and "default sort" + * are the same state. + */ +export interface DefaultedSortParams { + columns: readonly C[] + default: SortDefault + parsers: { sort: DefaultedParser; dir: DefaultedParser } +} + +/** + * Nullable sort params for lists where "no active sort" is behaviorally + * distinct from explicitly sorting by the fallback column (e.g. files: with no + * sort, files order by updated/desc but folders by name/asc). The params carry + * no defaults, so an explicit selection always persists in the URL and + * clearing writes `null` to strip both. + */ +export interface NullableSortParams { + columns: readonly C[] + default: null + parsers: { sort: SingleParserBuilder; dir: SingleParserBuilder } +} + +/** + * Builds the canonical `sort` + `dir` URL param pair for a sortable list (see + * `.claude/rules/sim-url-state.md`, "Sort convention"). Pass `defaultSort` + * when the list has a fixed default ordering (the common case); omit it for + * the nullable mode where "no active sort" is a distinct state. Consume with + * `useUrlSort` from `@/hooks/use-url-sort`. + */ +export function createSortParams( + columns: readonly C[], + defaultSort: SortDefault> +): DefaultedSortParams +export function createSortParams( + columns: readonly C[] +): NullableSortParams +export function createSortParams( + columns: readonly C[], + defaultSort?: SortDefault> +): DefaultedSortParams | NullableSortParams { + const sort = parseAsStringLiteral(columns) + const dir = parseAsStringLiteral(SORT_DIRECTIONS) + if (defaultSort) { + return { + columns, + default: defaultSort, + parsers: { + sort: sort.withDefault(defaultSort.column), + dir: dir.withDefault(defaultSort.direction), + }, + } + } + return { columns, default: null, parsers: { sort, dir } } +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 845241d3cd6..720a0830a3a 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -328,11 +328,22 @@ const nextConfig: NextConfig = { destination: 'https://discord.gg/Hr4UWYEcTT', permanent: false, }, + { + source: '/slack', + destination: + 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA', + permanent: false, + }, { source: '/x', destination: 'https://x.com/simdotai', permanent: false, }, + { + source: '/linkedin', + destination: 'https://www.linkedin.com/company/simstudioai/', + permanent: false, + }, { source: '/github', destination: 'https://github.com/simstudioai/sim', @@ -455,6 +466,25 @@ const nextConfig: NextConfig = { }) } + /** + * The comparison route was renamed from `/comparison` to `/comparisons` + * for naming consistency with `/integrations/[slug]` (plural category, + * singular item). Preserve previously indexed URLs for the hub page and + * every competitor detail page. + */ + redirects.push( + { + source: '/comparison', + destination: '/comparisons', + permanent: true, + }, + { + source: '/comparison/:path*', + destination: '/comparisons/:path*', + permanent: true, + } + ) + return redirects }, async rewrites() { diff --git a/apps/sim/public/static/slack-icon.png b/apps/sim/public/static/slack-icon.png new file mode 100644 index 00000000000..c560d6563bd Binary files /dev/null and b/apps/sim/public/static/slack-icon.png differ diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 33e3ab08616..1618461b105 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -29,5 +29,5 @@ annotations: url: https://docs.sim.ai - name: Source url: https://github.com/simstudioai/sim - - name: Discord - url: https://discord.gg/Hr4UWYEcTT + - name: Slack + url: https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA diff --git a/helm/sim/README.md b/helm/sim/README.md index affa22fc2c7..908aa970eac 100644 --- a/helm/sim/README.md +++ b/helm/sim/README.md @@ -473,7 +473,7 @@ kubectl --namespace sim logs job/sim-migrations * **Docs:** https://docs.sim.ai * **GitHub:** https://github.com/simstudioai/sim * **Issues:** https://github.com/simstudioai/sim/issues -* **Discord:** https://discord.gg/Hr4UWYEcTT +* **Slack:** https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA ---