chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Sim — Language & Positioning
|
||||
|
||||
When editing user-facing copy (landing pages, docs, metadata, marketing), follow these rules.
|
||||
|
||||
## Identity
|
||||
|
||||
Sim is the **AI workspace** where teams build and run AI agents. Not a workflow tool, not an agent framework, not an automation platform.
|
||||
|
||||
**Short definition:** Sim is the open-source AI workspace where teams build, deploy, and manage AI agents.
|
||||
|
||||
**Full definition:** Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work — visually, conversationally, or with code.
|
||||
|
||||
## Audience
|
||||
|
||||
**Primary:** Teams building AI agents for their organization — IT, operations, and technical teams who need governance, security, lifecycle management, and collaboration.
|
||||
|
||||
**Secondary:** Individual builders and developers who care about speed, flexibility, and open source.
|
||||
|
||||
## Required Language
|
||||
|
||||
| Concept | Use | Never use |
|
||||
|---------|-----|-----------|
|
||||
| The product | "AI workspace" | "workflow tool", "automation platform", "agent framework" |
|
||||
| Building | "build agents", "create agents" | "create workflows" (unless describing the workflow module specifically) |
|
||||
| Visual builder | "workflow builder" or "visual builder" | "canvas", "graph editor" |
|
||||
| The agent | "Sim" — you talk to Sim | "Mothership", "copilot", "AI assistant" |
|
||||
| The chat surface | "Chat" (capitalized, the module) | "Mothership", "copilot" |
|
||||
| Deployment | "deploy", "ship" | "publish", "activate" |
|
||||
| Audience | "teams", "builders" | "users", "customers" (in marketing copy) |
|
||||
| What agents do | "automate real work" | "automate tasks", "automate workflows" |
|
||||
| Our advantage | "open-source AI workspace" | "open-source platform" |
|
||||
|
||||
## Tone
|
||||
|
||||
- **Direct.** Short sentences. Active voice. Lead with what it does.
|
||||
- **Concrete.** Name specific things — "Slack bots, compliance agents, data pipelines" — not abstractions.
|
||||
- **Confident, not loud.** No exclamation marks or superlatives.
|
||||
- **Simple.** If a 16-year-old can't understand the sentence, rewrite it.
|
||||
|
||||
## Claim Hierarchy
|
||||
|
||||
When describing Sim, always lead with the most differentiated claim:
|
||||
|
||||
1. **What it is:** "The AI workspace for teams"
|
||||
2. **What you do:** "Build, deploy, and manage AI agents"
|
||||
3. **How:** "Visually, conversationally, or with code"
|
||||
4. **Scale:** "1,000+ integrations, every major LLM"
|
||||
5. **Trust:** "Open source. SOC2. Trusted by 100,000+ builders."
|
||||
|
||||
## Module Descriptions
|
||||
|
||||
| Module | One-liner |
|
||||
|--------|-----------|
|
||||
| **Chat** | Your AI command center. Talk to Sim — build and manage everything in natural language. |
|
||||
| **Workflows** | The visual builder. Connect blocks, models, and integrations into agent logic. |
|
||||
| **Knowledge Base** | Your agents' memory. Upload docs, sync sources, build vector databases. |
|
||||
| **Tables** | A database, built in. Store, query, and wire structured data into agent runs. |
|
||||
| **Files** | Upload, create, and share. One store for your team and every agent. |
|
||||
| **Logs** | Full visibility, every run. Trace execution block by block. |
|
||||
|
||||
## What We Never Say
|
||||
|
||||
- Never call Sim "just a workflow tool"
|
||||
- Never compare only on integration count — we win on AI-native capabilities
|
||||
- Never use "no-code" as the primary descriptor — say "visually, conversationally, or with code"
|
||||
- Never promise unshipped features
|
||||
- Never use jargon ("RAG", "vector database", "MCP") without plain-English explanation on public pages
|
||||
- Avoid "agentic workforce" as a primary term — use "AI agents"
|
||||
- Never say "Mothership" or "copilot" — the agent is "Sim", the surface is "Chat" (in run logs the trigger reads "Sim agent")
|
||||
|
||||
## Vision
|
||||
|
||||
Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Chat is another. The workspace is the constant; the interface adapts.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/components/emcn/**"
|
||||
---
|
||||
|
||||
# EMCN Components
|
||||
|
||||
Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from the `@sim/emcn/icons` subpath, and CSS modules from their file path. Never deep-import other component subpaths. The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover).
|
||||
|
||||
## Chip chrome — single source of truth
|
||||
|
||||
Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources:
|
||||
|
||||
- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, and `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells). All are re-exported from the `@sim/emcn` barrel — no subpath import needed.
|
||||
- **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity.
|
||||
|
||||
Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border.
|
||||
|
||||
The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items use `text-small` and `gap-2` (a menu convention, not the chip pill). Keep them distinct.
|
||||
|
||||
## Component catalogue
|
||||
|
||||
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `ghost`, `filled`, `primary`, `destructive`, `border-shadow`. `leftIcon`/`rightIcon`, `active`, `fullWidth`, `flush`.
|
||||
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
|
||||
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
|
||||
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
|
||||
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
|
||||
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
|
||||
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
|
||||
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
|
||||
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
|
||||
- **`ChipDatePicker`** — chip-styled date field.
|
||||
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
|
||||
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
|
||||
|
||||
## Authoring principles
|
||||
|
||||
- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
|
||||
- **`cn()` for a single state toggle, CVA for genuine multiple variants.** A lone `error` boolean is `cn()`, not a CVA variant.
|
||||
- **Discriminated-union props for modes** (e.g. `multiple`, the modal field `type`) instead of near-duplicate components.
|
||||
- **Delete legacy variants after migration** — don't leave dead paths (this paradigm removed `Input variant='chip'` and `ChipMultiSelect`).
|
||||
- **Verify CSS vars exist.** An undefined var resolves to `currentColor` (caused a real black-border bug). Align to the canonical tokens: normal weight, `--text-body`, `--text-icon`.
|
||||
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.
|
||||
|
||||
Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Global Standards
|
||||
|
||||
## Logging
|
||||
Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID.
|
||||
|
||||
## API Route Handlers
|
||||
All API route handlers must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## ID Generation
|
||||
Never use `crypto.randomUUID()`, `nanoid`, or the `uuid` package directly. Use the utilities from `@sim/utils/id`:
|
||||
|
||||
- `generateId()` — UUID v4, use by default
|
||||
- `generateShortId(size?)` — short URL-safe ID (default 21 chars), for compact identifiers
|
||||
|
||||
Both use `crypto.getRandomValues()` under the hood and work in all contexts including non-secure (HTTP) browsers.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
import { nanoid } from 'nanoid'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// ✓ Good
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
const uuid = generateId()
|
||||
const shortId = generateShortId()
|
||||
const tiny = generateShortId(8)
|
||||
```
|
||||
|
||||
## Common Utilities
|
||||
Use shared helpers from `@sim/utils` instead of writing inline implementations:
|
||||
|
||||
- `sleep(ms)` from `@sim/utils/helpers` — async delay. Never write `new Promise(resolve => setTimeout(resolve, ms))`
|
||||
- `toError(value)` from `@sim/utils/errors` — normalize unknown caught values to `Error`. Never write `e instanceof Error ? e : new Error(String(e))`
|
||||
- `getErrorMessage(value, fallback?)` from `@sim/utils/errors` — extract error message string. Never write `e instanceof Error ? e.message : 'fallback'`
|
||||
- `structuredClone(value)` — built-in deep clone, no import needed. Never write `JSON.parse(JSON.stringify(obj))`
|
||||
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
|
||||
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
|
||||
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
|
||||
- `backoffWithJitter(attempt, retryAfterMs, options?)` from `@sim/utils/retry` — exponential backoff with jitter
|
||||
- `parseRetryAfter(header)` from `@sim/utils/retry` — parse HTTP `Retry-After` header to milliseconds
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
const clone = JSON.parse(JSON.stringify(obj))
|
||||
const filtered = Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
|
||||
|
||||
// ✓ Good
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
await sleep(1000)
|
||||
const msg = getErrorMessage(error, 'Unknown error')
|
||||
const clone = structuredClone(obj)
|
||||
const filtered = filterUndefined(obj)
|
||||
```
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/app/(landing)/**/*.tsx"
|
||||
---
|
||||
|
||||
# Landing Page — SEO / GEO
|
||||
|
||||
## SEO
|
||||
|
||||
- One `<h1>` per page, in Hero only — never add another.
|
||||
- Strict heading hierarchy: H1 (Hero) → H2 (section titles) → H3 (feature names).
|
||||
- Every section: `<section id="…" aria-labelledby="…-heading">`.
|
||||
- Decorative/animated elements: `aria-hidden="true"`.
|
||||
- All internal routes use Next.js `<Link>` (crawlable). External links get `rel="noopener noreferrer"`.
|
||||
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element).
|
||||
- Navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
|
||||
- Feature lists must stay in sync with `WebApplication.featureList` in `structured-data.tsx`.
|
||||
|
||||
## GEO (Generative Engine Optimisation)
|
||||
|
||||
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
|
||||
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
|
||||
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
|
||||
- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI workspace", "AI agents".
|
||||
- **sr-only summaries**: Hero and Templates each have a `<p className="sr-only">` (~50 words) as an atomic product/catalog summary for AI citation.
|
||||
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**"
|
||||
---
|
||||
|
||||
# Sim App Architecture
|
||||
|
||||
## Core Principles
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||
|
||||
## Root-Level Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── sim/ # this app (Next.js: UI + API routes + workflow editor)
|
||||
│ ├── app/ # Next.js app router (pages, API routes)
|
||||
│ ├── blocks/ # Block definitions and registry
|
||||
│ ├── components/ # Shared UI (emcn/, ui/)
|
||||
│ ├── executor/ # Workflow execution engine
|
||||
│ ├── hooks/ # Shared hooks (queries/, selectors/)
|
||||
│ ├── lib/ # App-wide utilities
|
||||
│ ├── providers/ # LLM provider integrations
|
||||
│ ├── stores/ # Zustand stores
|
||||
│ ├── tools/ # Tool definitions
|
||||
│ └── triggers/ # Trigger definitions
|
||||
└── realtime/ # Bun Socket.IO server (collaborative canvas)
|
||||
|
||||
packages/ # @sim/* — audit, auth, db, logger, realtime-protocol,
|
||||
# security, tsconfig, utils, platform-authz,
|
||||
# workflow-persistence, workflow-types
|
||||
```
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
- `apps/* → packages/*` only. Packages never import from `apps/*`.
|
||||
- `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor; never add `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` imports to any package it consumes. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`.
|
||||
|
||||
## The `'use client'` server boundary
|
||||
|
||||
Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`.
|
||||
|
||||
## Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
├── hooks/ # Feature-scoped hooks
|
||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||
├── feature.tsx # Main component
|
||||
└── page.tsx # Next.js page entry
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Components**: PascalCase (`WorkflowList`)
|
||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||
- **Stores**: `stores/feature/store.ts`
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||
|
||||
## Utils Rules
|
||||
|
||||
- **Never create `utils.ts` for single consumer** - inline it
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Component Patterns
|
||||
|
||||
Component authoring rules — structure order (refs → external hooks → store hooks → custom hooks → state → useMemo → useCallback → useEffect → return), required props interface, and extraction thresholds (50+ lines / 2+ files vs keep inline < 10 lines) — live in CLAUDE.md > Components.
|
||||
|
||||
`.tsx`-specific deltas not covered there:
|
||||
|
||||
- `'use client'` only when using React hooks or browser-only APIs.
|
||||
- Prefer semantic HTML (`aside`, `nav`, `article`).
|
||||
- Optional-chain callbacks: `onAction?.(id)`.
|
||||
|
||||
## List-render performance
|
||||
|
||||
When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1):
|
||||
|
||||
- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map<string, T>(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array.
|
||||
- **Sort with `[...array].sort(cmp)` in client code — NOT `array.toSorted(cmp)`.** SWC (Next's compiler) transforms syntax but does not polyfill prototype methods, and the repo sets no core-js/browserslist target, so `toSorted`/`toReversed`/`toSpliced`/`Array.prototype.with` ship as-is and throw `TypeError` on Safari <16 / iOS 15 (they landed in Safari 16). `tsconfig` `lib: ES2023` only affects type-checking, never the runtime. The `[...arr].sort()` spread copy is the intended cost — it keeps the sort non-mutating without an unpolyfilled builtin. These methods are only safe in server-only modules (no `'use client'`, executed on Node ≥20). react-doctor's `js-tosorted-immutable` is therefore a won't-fix in client components.
|
||||
- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice.
|
||||
|
||||
## react-doctor (`npx react-doctor`) — apply the wins, skip the false positives
|
||||
|
||||
react-doctor diagnostics are hypotheses, not verdicts — confirm against the code before acting, and preserve behavior. Known repo-specific false positives to NOT "fix":
|
||||
|
||||
- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them.
|
||||
- `js-tosorted-immutable` — in `'use client'` code, keep `[...arr].sort(cmp)`; `toSorted` is unpolyfilled and crashes Safari <16 / iOS 15 (see "List-render performance" above). Only apply it in server-only modules.
|
||||
- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively.
|
||||
- `no-render-in-render` — a helper *called inline* (`{renderRow()}`) is reconciled by position and does **not** remount, so extracting it to a component is usually pure churn and can regress behavior (prop-drilling many closures, focus/scroll loss on the inner `<input>`). Apply it only when the helper is genuinely a *component defined during render*, or when the move is mechanical (a stateless, ref-free helper whose closures become a small, explicit prop set).
|
||||
- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it.
|
||||
- Broad refactors (`prefer-useReducer` for many `useState`, `no-giant-component` splits) — out of scope for a perf pass; note, don't churn.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/use-*.ts"
|
||||
- "apps/sim/**/hooks/**/*.ts"
|
||||
---
|
||||
|
||||
# Hook Patterns
|
||||
|
||||
## Structure
|
||||
|
||||
For server data, use a React Query hook from `hooks/queries/` — do NOT `useState` + `fetch` here (see `.claude/rules/sim-queries.md`). This pattern is for UI/orchestration hooks that hold UI-only state and wrap callbacks.
|
||||
|
||||
```typescript
|
||||
interface UseFeatureProps {
|
||||
id: string
|
||||
onSelect?: (item: Item) => void
|
||||
}
|
||||
|
||||
export function useFeature({ id, onSelect }: UseFeatureProps) {
|
||||
// 1. Refs for stable dependencies
|
||||
const idRef = useRef(id)
|
||||
const onSelectRef = useRef(onSelect)
|
||||
|
||||
// 2. UI-only state (never server data)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// 3. Sync refs
|
||||
useEffect(() => {
|
||||
idRef.current = id
|
||||
onSelectRef.current = onSelect
|
||||
}, [id, onSelect])
|
||||
|
||||
// 4. Operations (useCallback with empty deps when using refs)
|
||||
const select = useCallback((item: Item) => {
|
||||
onSelectRef.current?.(item)
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
return { isOpen, setIsOpen, select }
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Single responsibility per hook
|
||||
2. Props interface required
|
||||
3. Refs for stable callback dependencies
|
||||
4. Wrap returned functions in useCallback
|
||||
5. Server data goes through React Query (`hooks/queries/`), never `useState` + `fetch`
|
||||
6. Keep only UI/orchestration state in these hooks
|
||||
|
||||
## State shape
|
||||
|
||||
Never mirror a prop into state with `useState(prop)` + a syncing `useEffect` — a prop change clobbers in-progress local edits. Use the prop directly, reset via a remount `key`, or — when you must seed local state from a prop only on a transition (e.g. a modal opening) — adjust it **during render** with a `prev`-value tracker held in `useState`:
|
||||
|
||||
```typescript
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open)
|
||||
if (open) setName(initialName) // closed → open only
|
||||
}
|
||||
```
|
||||
|
||||
React re-renders immediately with the corrected state without committing the stale value. Rules: the `if (prev !== current)` guard is mandatory (an unconditional `setState` in render loops forever), the tracker is set **inside** the guard, and you may only set the currently-rendering component's state this way. Hold the tracker in `useState`, **not a `useRef`** — React forbids reading/writing `ref.current` during render (react.dev, useRef → "Do not write _or read_ `ref.current` during rendering"; the `react-hooks` `refs` lint flags it), and a `useState` tracker is concurrent-safe where a mutated ref is not (a discarded render rolls state back, not a ref).
|
||||
|
||||
**The tracker's initial value decides mount behavior — choose it deliberately.** The example seeds `useState(open)` because the modal mounts closed, so the first render's guard is `false` and nothing resets on mount (correct — `name` is already at its initial value). When the effect you're replacing did real work **on mount** — opening a panel because a prop already matches, seeding editable state from an already-present value, or a component that can mount in the active state — seed a **sentinel** the live value can't equal (e.g. `useState<T | null>(null)`), otherwise the guard is `false` on the first render and that mount action is silently dropped. Place the block before any early `return`.
|
||||
|
||||
> Some existing components use a `useRef` prev-tracker (`if (prevRef.current !== x) { prevRef.current = x; … }`). It works but reads/writes a ref during render — prefer the `useState` form above for new code.
|
||||
|
||||
Only convert a `useState` to a `useRef` when the value is **never read during render/JSX and is never a hook dependency** — a value in a `useEffect`/`useMemo`/`useCallback` dep array must re-run the hook on change, so it stays state (see also `rerender-state-only-in-handlers` in `sim-components.md`). Convert only set-only values read solely inside handlers or effect bodies (e.g. a prompt-history index, a pending-upload URL). If a ref feeds render, mutating it won't re-render and the UI goes stale.
|
||||
|
||||
Model mutually-exclusive flags as ONE `status` enum, not several contradictory booleans. `isLoading`/`isVerified`/`isInvalidOtp` describing one machine collapse to `status: 'idle' | 'verifying' | 'verified' | 'error'` (+ `errorMessage`); derive any boolean a consumer still needs (`status === 'error'`).
|
||||
|
||||
Derive busy/success from the mutation object — never duplicate `mutation.isPending`/`mutation.isSuccess` into local `useState`. Read them directly (`mutation.isSuccess`) and reset with `mutation.reset()`. A distinct phase the mutation doesn't cover — e.g. a pre-submit captcha/Turnstile gate that runs before `mutate()` — is not a duplicate; keep that flag.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Import Patterns
|
||||
|
||||
## Absolute Imports
|
||||
|
||||
**Always use absolute imports.** Never use relative imports.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { useWorkflowStore } from '@/stores/workflows/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// ✗ Bad
|
||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
||||
```
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
|
||||
// ✗ Bad
|
||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
||||
```
|
||||
|
||||
## Code-splitting through barrels
|
||||
|
||||
When you `lazy(() => import(...))` a component to keep it out of a route's initial bundle, import the **deep module path** (`./components/foo/foo`), never the barrel — and **delete the now-dead barrel re-export** of that component. This app has no `"sideEffects": false` in `apps/sim/package.json`, so when any sibling still imports that barrel, webpack can conservatively keep the barrel's re-export edge to the heavy module. A leftover `export { Foo } from './foo'` line can therefore drag `Foo` (and its transitive deps) back into the initial chunk and silently defeat the split. Removing the dead re-export is the guaranteed fix; verify with a production bundle diff, not by eyeballing the `lazy()` call.
|
||||
|
||||
```typescript
|
||||
// ✓ Good — deep lazy import + no barrel edge left behind
|
||||
const MothershipView = lazy(() =>
|
||||
import('./components/mothership-view/mothership-view').then((m) => ({ default: m.MothershipView }))
|
||||
)
|
||||
// (and remove `export { MothershipView } from './mothership-view'` from components/index.ts)
|
||||
```
|
||||
|
||||
Wrap the lazy component in a **local `<Suspense>`** so its suspension resolves at the nearest boundary instead of bubbling to the page-level fallback (which would flash the whole route). `React.lazy(memo(forwardRef(...)))` forwards a DOM `ref` correctly in React 19 — but during the fallback window `ref.current` is `null`, so every consumer must guard it (`if (!el) return` / `el?.`).
|
||||
|
||||
## No Re-exports
|
||||
|
||||
Do not re-export from non-barrel files. Import directly from the source.
|
||||
|
||||
```typescript
|
||||
// ✓ Good - import from where it's declared
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
||||
```
|
||||
|
||||
## Import Order
|
||||
|
||||
1. React/core libraries
|
||||
2. External libraries
|
||||
3. UI components (`@sim/emcn`, `@/components/ui`)
|
||||
4. Utilities (`@/lib/...`)
|
||||
5. Stores (`@/stores/...`)
|
||||
6. Feature imports
|
||||
7. CSS imports
|
||||
|
||||
## Type Imports
|
||||
|
||||
Use `type` keyword for type-only imports:
|
||||
|
||||
```typescript
|
||||
import type { WorkflowLog } from '@/stores/logs/types'
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/tools/**"
|
||||
- "apps/sim/blocks/**"
|
||||
- "apps/sim/triggers/**"
|
||||
---
|
||||
|
||||
# Adding Integrations
|
||||
|
||||
Build order: **Tools** (`tools/{service}/`) → **Block** (`blocks/blocks/{service}.ts`) → **Icon** (`components/icons.tsx`) → optional **Trigger** (`triggers/{service}/`). Always look up the service's API docs first.
|
||||
|
||||
The full authoring instructions — tool/block/icon/trigger scaffolding, SubBlock property tables, `condition`/`required`/`dependsOn`/`mode`/`canonicalParamId` syntax, the `normalizeFileInput`/file-handling helper table, and checklists — live in the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`.
|
||||
|
||||
## Hard rules (don't get these wrong)
|
||||
|
||||
- Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`.
|
||||
- Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `<Block.output>` references).
|
||||
- `canonicalParamId` must NOT match any subblock's `id`, must be unique per operation/condition context, and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs.
|
||||
- Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details.
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/hooks/queries/**/*.ts"
|
||||
---
|
||||
|
||||
# React Query Patterns
|
||||
|
||||
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
|
||||
|
||||
For *client* view-state that belongs in a shareable link (tabs, filters, search, pagination, selected entity id), use URL query params via nuqs — see `.claude/rules/sim-url-state.md`. React Query owns remote data; nuqs owns shareable client view-state.
|
||||
|
||||
## Query Key Factory
|
||||
|
||||
Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
lists: () => [...entityKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
|
||||
details: () => [...entityKeys.all, 'detail'] as const,
|
||||
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
|
||||
}
|
||||
```
|
||||
|
||||
Never use inline query keys — always use the factory.
|
||||
|
||||
**Every identifier the `queryFn` forwards into the fetch MUST appear in the `queryKey`.** (Query-machinery identifiers — `signal`, `pageParam` — are exempt; they aren't fetch-scoping args.) If the fetch is scoped by `workspaceId`, `cursor`, `limit`, an org id, etc., those values must be part of the key — otherwise distinct fetch args share one cache entry (a cross-tenant / per-param cache collision). The lone exception is a globally-unique id used as the key while a second fetch arg is only an authz scope that cannot collide; annotate those with `// rq-lint-allow: <reason>`. Enforced by the `key-fetch-arg-drift` check in `scripts/check-react-query-patterns.ts`.
|
||||
|
||||
## Server-importable query primitives must NOT live in a `'use client'` module
|
||||
|
||||
Next.js rewrites **every** export of a `'use client'` module into a *client reference* in the server bundle. Server-evaluated code — RSC `page.tsx`/`layout.tsx`, `prefetch.ts`, route handlers, **block definitions**, triggers/workers — can only *render* such an export as a component or pass it as a prop; **calling** one throws at runtime (`Attempted to call X from the server but X is on the client` — for an object export it surfaces as `X.list is not a function`). `next build` does **not** catch this — only SSR/runtime does.
|
||||
|
||||
So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module:
|
||||
|
||||
- key factories → `hooks/queries/utils/<entity>-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`)
|
||||
- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-workspace-credentials.ts`)
|
||||
|
||||
The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: <reason>` on the line above the import.
|
||||
|
||||
## File Structure
|
||||
|
||||
```typescript
|
||||
// 1. Query keys factory
|
||||
// 2. Types (if needed)
|
||||
// 3. Private fetch functions (accept signal parameter)
|
||||
// 4. Exported hooks
|
||||
```
|
||||
|
||||
## Query Hook
|
||||
|
||||
- Every `queryFn` must destructure and forward `signal` for request cancellation
|
||||
- Every query must have an explicit `staleTime`, assigned from a named exported constant (`ENTITY_LIST_STALE_TIME`), never an inline numeric literal. A server-side prefetch (`prefetch.ts`) hydrating the same query key must import and reuse that constant, not restate the number — this is what keeps a prefetched cache entry from going stale out of sync with the client hook that reads it
|
||||
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
|
||||
- Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**`
|
||||
|
||||
```typescript
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
|
||||
|
||||
export const ENTITY_LIST_STALE_TIME = 60 * 1000
|
||||
|
||||
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
|
||||
const data = await requestJson(listEntitiesContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.entities
|
||||
}
|
||||
|
||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: ENTITY_LIST_STALE_TIME,
|
||||
placeholderData: keepPreviousData, // OK: workspaceId varies
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
|
||||
- Invalidation must cover all affected query key prefixes (lists, details, related views)
|
||||
- Use `onSuccess` invalidation for plain mutations; use `onSettled` for optimistic mutations so the cache is reconciled on both success and error (see Optimistic Updates below)
|
||||
- `mutationFn` calls go through `requestJson(contract, { body, signal })` from `@/lib/api/client/request` — same boundary rule as queries
|
||||
|
||||
```typescript
|
||||
export function useCreateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateEntityBody) => requestJson(createEntityContract, { body }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For optimistic mutations, use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error, ensuring the cache is always reconciled with the server.
|
||||
|
||||
```typescript
|
||||
export function useUpdateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* ... */ },
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic value */)
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||
|
||||
## useCallback Dependencies
|
||||
|
||||
Never include mutation objects (e.g., `createEntity`) in `useCallback` dependency arrays — the mutation object is not referentially stable and changes on every state update. The `.mutate()` and `.mutateAsync()` functions are stable in TanStack Query v5.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — causes unnecessary recreations
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
}, [createEntity]) // unstable reference
|
||||
|
||||
// ✓ Good — omit from deps, mutate is stable
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
```
|
||||
|
||||
## Boundary Types
|
||||
|
||||
- Hooks import named type aliases from `@/lib/api/contracts/**` (e.g., `import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'`). Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
|
||||
- Raw `fetch` is allowed only for documented exceptions — multipart uploads, binary downloads, streaming responses, signed-URL flows, OAuth redirects, external origins. Each such raw `fetch(` inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**` — and any same-origin `/api/...` fetch elsewhere under `apps/sim/**` outside an API route handler — must be preceded by a `// boundary-raw-fetch: <reason>` annotation (reason non-empty; up to three preceding comment lines tolerated). Enforced by `scripts/check-api-validation-contracts.ts` (`bun run check:api-validation` / `:strict`).
|
||||
|
||||
## Naming
|
||||
|
||||
- **Keys**: `entityKeys`
|
||||
- **Query hooks**: `useEntity`, `useEntityList`
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity`
|
||||
- **Fetch functions**: `fetchEntity`, `fetchEntities` (private)
|
||||
|
||||
## Enforcement
|
||||
|
||||
`scripts/check-react-query-patterns.ts` (`bun run check:react-query`, run in CI) statically enforces these conventions: every `useQuery`/`useInfiniteQuery`/`useSuspenseQuery` declares an explicit `staleTime`, inline `queryFn`s destructure `signal`, `queryKey`s reference a colocated factory rather than an inline literal, every `*Keys` factory in `hooks/queries/**` exposes an `all` root key, and every identifier the `queryFn` forwards into the fetch also appears in the `queryKey` (`key-fetch-arg-drift`). `hooks/queries/**` is a zero-tolerance zone; the rest of `apps/sim/**` is ratcheted against `scripts/check-react-query-patterns.baseline.json`. For a genuine exception, put `// rq-lint-allow: <reason>` on the line directly above the flagged construct.
|
||||
@@ -0,0 +1,95 @@
|
||||
# React & Render Performance
|
||||
|
||||
Behavior-preserving performance idioms for components, hooks, and hot render paths. These are safe defaults — apply them freely. For the render-causing *effect/state* anti-patterns (derived state in effects, effect chains, state synced to a prop), use the dedicated skills: `/you-might-not-need-an-effect`, `/you-might-not-need-state`, `/you-might-not-need-a-memo`, `/you-might-not-need-a-callback`. Those refactors change render timing — verify them against the running UI, never mass-apply blind.
|
||||
|
||||
## Lazy-init refs that hold objects
|
||||
|
||||
`useRef(new Map())` / `useRef(new Set())` / `useRef({...})` allocates a fresh object on **every render** and throws it away — only the first is ever kept. Lazy-init instead so the allocation happens once.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — allocates a new Map each render, discards all but the first
|
||||
const cacheRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// ✓ Good — allocated once, stable identity thereafter
|
||||
const cacheRef = useRef<Map<string, string> | null>(null)
|
||||
cacheRef.current ??= new Map()
|
||||
```
|
||||
|
||||
Read `cacheRef.current` directly inside effects/handlers — refs are stable and never belong in a dependency array. A cheap primitive (`useRef(0)`, `useRef('')`, `useRef(null)`) needs no lazy init.
|
||||
|
||||
## Hoist static values and closure-free functions to module scope
|
||||
|
||||
A value or function declared inside a component is rebuilt every render. If it captures **nothing** from component scope (no props/state/refs), move it above the component at module scope. This skips the per-render allocation and keeps a stable identity so memoized children don't re-render.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — rebuilt every render, new identity each time
|
||||
function Toolbar({ mode }: ToolbarProps) {
|
||||
const TITLES = { create: 'Add', edit: 'Configure' } as const
|
||||
const handleWheel = (e: React.WheelEvent) => e.currentTarget.scrollBy(e.deltaX, e.deltaY)
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✓ Good — allocated once at module load
|
||||
const TITLES = { create: 'Add', edit: 'Configure' } as const
|
||||
function handleWheel(e: React.WheelEvent) {
|
||||
e.currentTarget.scrollBy(e.deltaX, e.deltaY)
|
||||
}
|
||||
function Toolbar({ mode }: ToolbarProps) { /* ... */ }
|
||||
```
|
||||
|
||||
A closure-free function that IS wired through a ref sink or intentionally kept for stable identity may stay inline — hoisting a one-line `preventDefault` handler is churn, not a win. Hoist when it removes a real per-render allocation or unblocks child memoization.
|
||||
|
||||
## Pre-index with Map/Set for repeated lookups
|
||||
|
||||
`array.find()` / `array.includes()` / `array.indexOf()` scan the whole list each call. Inside a loop or a hot render path over a non-trivial list, that is O(n·m). Build a `Map` (for lookup-by-key) or `Set` (for membership) **once before** the loop, then look up in O(1).
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — find() re-scans outputs for every column
|
||||
for (const child of columns) {
|
||||
const output = group.outputs.find((o) => o.columnName === getColumnId(child))
|
||||
}
|
||||
|
||||
// ✓ Good — index once, then O(1) lookups
|
||||
const outputByName = new Map<string, Output>()
|
||||
for (const o of group.outputs) {
|
||||
if (!outputByName.has(o.columnName)) outputByName.set(o.columnName, o) // first wins, matches find()
|
||||
}
|
||||
for (const child of columns) {
|
||||
const output = outputByName.get(getColumnId(child))
|
||||
}
|
||||
```
|
||||
|
||||
Preserve `.find()`'s **first-match** semantics when duplicate keys are possible: `new Map(arr.map(...))` keeps the *last* entry, so guard with `if (!map.has(key))` when replacing a `.find()`. Skip this for tiny, cold arrays (a handful of items in an event handler) where the Map build costs more than it saves.
|
||||
|
||||
## Never mutate a shared array in place
|
||||
|
||||
The real bug to avoid is `array.sort()` / `array.reverse()` on an array you don't own — sorting a React Query cache array in place corrupts shared state. Always sort a copy:
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — mutates the (possibly shared) source array in place
|
||||
return items.sort(compare)
|
||||
|
||||
// ✓ Good — sorts a throwaway copy, source untouched
|
||||
return [...items].sort(compare)
|
||||
```
|
||||
|
||||
**Do NOT reach for `toSorted()` / `toReversed()` / `with()` / `toSpliced()` on client render paths.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is the correct default everywhere client code runs. Only consider the immutable methods in Node-only code (server routes, scripts) on Node ≥20, where the runtime is known.
|
||||
|
||||
## Run independent awaits in parallel
|
||||
|
||||
Sequential `await`s that don't consume each other's result serialize latency for nothing — in an async Server Component or a route handler this directly delays the response. Kick them off together with `Promise.all` and destructure.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — waits for params, then separately waits for searchParams
|
||||
const { id } = await params
|
||||
const { kbName } = await searchParams
|
||||
|
||||
// ✓ Good — one combined wait
|
||||
const [{ id }, { kbName }] = await Promise.all([params, searchParams])
|
||||
```
|
||||
|
||||
Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).
|
||||
|
||||
## Local feature barrels are the convention — do not "fix" them
|
||||
|
||||
Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
description: Isolated-vm sandbox worker security policy. Hard rules for anything that lives in the worker child process that runs user code.
|
||||
paths:
|
||||
- "apps/sim/lib/execution/isolated-vm-worker.cjs"
|
||||
- "apps/sim/lib/execution/isolated-vm.ts"
|
||||
- "apps/sim/lib/execution/sandbox/**"
|
||||
- "apps/sim/sandbox-tasks/**"
|
||||
---
|
||||
|
||||
# Sim Sandbox — Worker Security Policy
|
||||
|
||||
The isolated-vm worker child process at
|
||||
`apps/sim/lib/execution/isolated-vm-worker.cjs` runs untrusted user code inside
|
||||
V8 isolates. The process itself is a trust boundary. Everything in this rule is
|
||||
about what must **never** live in that process.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **No app credentials in the worker process**. The worker must not hold, load,
|
||||
or receive via IPC: database URLs, Redis URLs, AWS keys, Stripe keys,
|
||||
session-signing keys, encryption keys, OAuth client secrets, internal API
|
||||
secrets, or any LLM / email / search provider API keys. If you catch yourself
|
||||
`require`'ing `@/lib/auth`, `@sim/db`, `@/lib/uploads/core/storage-service`,
|
||||
or anything that imports `env` directly inside the worker, stop and use a
|
||||
host-side broker instead.
|
||||
|
||||
2. **Host-side brokers own all credentialed work**. The worker can only access
|
||||
resources through `ivm.Reference` / `ivm.Callback` bridges back to the host
|
||||
process. Today the only broker is `workspaceFileBroker`
|
||||
(`apps/sim/lib/execution/sandbox/brokers/workspace-file.ts`); adding a new
|
||||
one requires co-reviewing this file.
|
||||
|
||||
3. **Host-side brokers must scope every resource access to a single tenant**.
|
||||
The `SandboxBrokerContext` always carries `workspaceId`. Any new broker that
|
||||
accesses storage, DB, or an external API must use `ctx.workspaceId` to scope
|
||||
the lookup — never accept a raw path, key, or URL from isolate code without
|
||||
validation.
|
||||
|
||||
4. **Nothing that runs in the isolate is trusted, even if we wrote it**. The
|
||||
task `bootstrap` and `finalize` strings in `apps/sim/sandbox-tasks/` execute
|
||||
inside the isolate. They must treat `globalThis` as adversarial — no pulling
|
||||
values from it that might have been mutated by user code. The hardening
|
||||
script in `executeTask` undefines dangerous globals before user code runs.
|
||||
|
||||
## Why
|
||||
|
||||
A V8 JIT bug (Chrome ships these roughly monthly) gives an attacker a native
|
||||
code primitive inside the process that owns whatever that process can reach.
|
||||
If the worker only holds `isolated-vm` + a single narrow workspace-file broker,
|
||||
a V8 escape leaks one tenant's files. If the worker holds a Stripe key or a DB
|
||||
connection, a V8 escape leaks the service.
|
||||
|
||||
The original `doc-worker.cjs` vulnerability (CVE-class, 225 production secrets
|
||||
leaked via `/proc/1/environ`) was the forcing function for this architecture.
|
||||
Keep the blast radius small.
|
||||
|
||||
## Checklist for changes to `isolated-vm-worker.cjs`
|
||||
|
||||
Before landing any change that adds a new `require(...)` or `process.send(...)`
|
||||
payload or `ivm.Reference` wrapper in the worker:
|
||||
|
||||
- [ ] Does it load a credential, key, connection string, or secret? If yes,
|
||||
move it host-side and expose as a broker.
|
||||
- [ ] Does it import from `@/lib/auth`, `@sim/db`, `@/lib/uploads/core/*`,
|
||||
`@/lib/core/config/env`, or any module that reads `process.env` of the
|
||||
main app? If yes, same — move host-side.
|
||||
- [ ] Does it expose a resource that's workspace-scoped without taking a
|
||||
`workspaceId`? If yes, re-scope.
|
||||
- [ ] Did you update the broker limits (`IVM_MAX_BROKER_ARGS_JSON_CHARS`,
|
||||
`IVM_MAX_BROKER_RESULT_JSON_CHARS`, `IVM_MAX_BROKERS_PER_EXECUTION`) if
|
||||
the new broker can emit large payloads or fire frequently?
|
||||
|
||||
## What the worker *may* hold
|
||||
|
||||
- `isolated-vm` module
|
||||
- Node built-ins: `node:fs` (only for reading the checked-in bundle `.cjs`
|
||||
files) and `node:path`
|
||||
- The three prebuilt library bundles under
|
||||
`apps/sim/lib/execution/sandbox/bundles/*.cjs`
|
||||
- IPC message handlers for `execute`, `cancel`, `fetchResponse`,
|
||||
`brokerResponse`
|
||||
|
||||
The worker deliberately has **no host-side logger**. All errors and
|
||||
diagnostics flow through IPC back to the host, which has `@sim/logger`. Do
|
||||
not add `createLogger` or console-based logging to the worker — it would
|
||||
require pulling the main app's config / env, which is exactly what this
|
||||
rule is preventing.
|
||||
|
||||
Anything else is suspect.
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/app/workspace/*/settings/**"
|
||||
- "apps/sim/ee/**/components/**"
|
||||
---
|
||||
|
||||
# Settings Pages
|
||||
|
||||
The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
|
||||
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
|
||||
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
|
||||
by a **title + description from navigation metadata**. The chrome stays mounted
|
||||
across section navigation (it never re-renders or re-lays-out). Each section
|
||||
renders through the **`SettingsPanel`** registrar
|
||||
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
|
||||
the shell its header data and renders only the section body. Sections supply
|
||||
**data**, never chrome.
|
||||
|
||||
Do NOT hand-roll any of these in a settings page — they are owned by the layout
|
||||
shell (fed through `SettingsPanel`):
|
||||
|
||||
- `<div className='flex h-full flex-col bg-[var(--bg)]'>` shell
|
||||
- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`)
|
||||
- the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`)
|
||||
- the content column (`mx-auto … max-w-[48rem] … gap-7`)
|
||||
- a title block (`<h1 className='font-medium text-[var(--text-body)] text-lg'>` + `<p className='text-[var(--text-muted)] text-md'>`)
|
||||
- the page-level search input
|
||||
|
||||
## Canonical page shape
|
||||
|
||||
```tsx
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
actions={[{ text: 'Create', icon: Plus, variant: 'primary', onSelect: onCreate }]}
|
||||
search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }}
|
||||
>
|
||||
{/* body only — sections, lists, forms */}
|
||||
</SettingsPanel>
|
||||
)
|
||||
```
|
||||
|
||||
When the page has modal/dialog siblings, wrap them with the panel in a fragment:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel actions={…}>{body}</SettingsPanel>
|
||||
<SomeModal … />
|
||||
</>
|
||||
)
|
||||
```
|
||||
|
||||
## `SettingsPanel` props
|
||||
|
||||
- `actions?: SettingsAction[]` — right-aligned header chips, **data only**:
|
||||
`{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`.
|
||||
The shell renders each as a `Chip` — never pass JSX, a `<div>`, or `className`
|
||||
(the locked contract: it's structurally impossible to vibe-code a padding
|
||||
change). Multiple/conditional actions are a plain array
|
||||
(`[...(canManage ? [{…}] : []), …]`). Labels are **sentence case** (`Add override`,
|
||||
not `Add Override`). A disabled action that needs to explain itself sets
|
||||
`tooltip` (the shell renders the hover tooltip, disabled chip included) — never
|
||||
hand-roll a tooltip-wrapped chip in `aside`. Save/Discard pairs come from the
|
||||
`saveDiscardActions()` helper (spread it into `actions`). Only a widget that
|
||||
genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in `aside`.
|
||||
- `back?: SettingsBackAction` (`{ text, icon?, onSelect }`) — left-aligned back
|
||||
chip for a **detail sub-view** (e.g. a selected MCP server, a permission group,
|
||||
a retention policy). Detail sub-views render through `SettingsPanel` like list
|
||||
pages — they do NOT hand-roll their own shell.
|
||||
- `aside?: ReactNode` — escape hatch for the rare non-chip header widget. Keep it rare.
|
||||
- `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` —
|
||||
renders the canonical search field directly below the title. Pass `setSearchTerm`
|
||||
straight to `onChange`. Use this for a standalone search; if search shares a row
|
||||
with other controls (sort, filters, a date picker), render that whole row in
|
||||
`children` instead and omit the prop.
|
||||
- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a
|
||||
detail sub-view that needs a different heading; normal pages never pass these.
|
||||
- `scrollContainerRef?: React.Ref<HTMLDivElement>` — forwards a ref to the scroll
|
||||
region (e.g. programmatic scroll-to-bottom).
|
||||
|
||||
## Title + description live in navigation metadata
|
||||
|
||||
`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source
|
||||
of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel`
|
||||
resolves both via `getSettingsSectionMeta(section)` and the
|
||||
`SettingsSectionProvider` the settings shell wraps around the active section.
|
||||
|
||||
Adding a new settings page:
|
||||
|
||||
1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and**
|
||||
`description`) in `navigation.ts`. Keep descriptions verb-first, one line,
|
||||
~40–55 chars, in the product voice (see `.claude/rules/constitution.md`).
|
||||
2. Render the component inside the shell's `effectiveSection` switch in
|
||||
`settings/[section]/settings.tsx`.
|
||||
3. Build the component body inside `<SettingsPanel>` — no shell, no title block.
|
||||
|
||||
## Text-scale tokens (no literal pixel sizes)
|
||||
|
||||
Settings pages never use a literal `text-[Npx]` class — always the named Tailwind
|
||||
scale token from `apps/sim/tailwind.config.ts`'s `fontSize` extension (`text-micro`
|
||||
10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px
|
||||
[Tailwind default, unmodified], `text-base` 15px, `text-md` 16px, `text-lg` 18px
|
||||
[Tailwind default]). A literal size is either a straight rename to the equivalent
|
||||
token (if the pixel value matches one exactly) or a sign the page never migrated —
|
||||
grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and
|
||||
`apps/sim/ee/**` to find stragglers.
|
||||
|
||||
For a two-line list row (title/value on top, a muted subtitle below — a name +
|
||||
email, a tool name + description, a server name + status), the established
|
||||
pairing is:
|
||||
|
||||
- **Title / row value**: `text-[var(--text-body)] text-sm`
|
||||
- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption`
|
||||
|
||||
This is not a stylistic guess — it is the tokenized form of the literal-pixel
|
||||
pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px]
|
||||
text-[var(--text-muted)]`) already used for this exact row shape across
|
||||
`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`,
|
||||
`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather
|
||||
than inventing a new size pairing.
|
||||
|
||||
For a toggle row (a `Switch` with a title and optional description), use the emcn
|
||||
`Label` component for the title — never a hand-rolled `<span>` — paired with
|
||||
`Switch`'s `id`/`Label`'s `htmlFor`:
|
||||
|
||||
```tsx
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label htmlFor='my-toggle'>Enable thing</Label>
|
||||
<p className='text-[var(--text-muted)] text-caption'>One-line description.</p>
|
||||
</div>
|
||||
<Switch id='my-toggle' checked={enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
```
|
||||
|
||||
`Label`'s own default styling (`font-medium text-[var(--text-primary)]
|
||||
text-small`) already matches the established title treatment — do not add a
|
||||
`className` overriding its size/color unless the row genuinely needs something
|
||||
different.
|
||||
|
||||
`--text-primary`/`--text-secondary` and `--text-body`/`--text-muted` are both real,
|
||||
independently-defined tokens (not interchangeable — they resolve to different
|
||||
colors) and both see legitimate use across settings pages; this rule only pins
|
||||
down the **row title/subtitle** shape above, not every text element on every page.
|
||||
|
||||
## Other shared settings primitives (do not re-roll these)
|
||||
|
||||
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
|
||||
muted status message. `variant='fill'` (default) centers in the available
|
||||
height (empty list, or a not-entitled/loading gate); `variant='inline'` sits in
|
||||
flow (a search "no results"). Never hand-roll
|
||||
`<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>`
|
||||
or `<div className='py-4 text-center …'>`. It owns the `--text-muted` + `text-sm`
|
||||
tokens, so it also keeps these messages consistent across pages.
|
||||
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
|
||||
actions menu for a list row. Pass `label` (aria-label) and
|
||||
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
|
||||
component renders the canonical flush `...` trigger + `DropdownMenuContent`.
|
||||
Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never
|
||||
hand-roll the `<DropdownMenu>` + `<MoreHorizontal>` trigger per page.
|
||||
|
||||
## Save / Discard + unsaved-changes guard
|
||||
|
||||
Any settings surface with editable state uses **one** shared stack — never
|
||||
hand-roll a Save button, a Discard button, a `beforeunload`, or an "Unsaved
|
||||
changes" modal:
|
||||
|
||||
- **`saveDiscardActions(config)`** (`…/components/save-discard-actions/save-discard-actions`)
|
||||
— returns the canonical dirty-gated **Discard + Save** `SettingsAction[]` (empty
|
||||
when not dirty). Spread it into a `SettingsPanel` `actions` array, beside any
|
||||
sibling actions (a detail view's Delete / Remove override). Config: `dirty`,
|
||||
`saving`, `onSave`, `onDiscard`, `saveDisabled?`, `saveLabel?`, `savingLabel?`.
|
||||
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
|
||||
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
|
||||
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
|
||||
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
|
||||
confirmDiscard }` for a detail view's **in-view back** chip.
|
||||
- **Top-level pages** (whitelabeling, sso): call it **unassigned** —
|
||||
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
|
||||
store-sync; the sidebar/`beforeunload` do the rest.
|
||||
- **Detail sub-views** (data-retention, access-control group-detail): route the
|
||||
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
|
||||
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
|
||||
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
|
||||
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
|
||||
in-view header **Discard** chip (via `SaveDiscardActions onDiscard`) is a
|
||||
*reset to original* — distinct from the back-confirm's discard, which leaves.
|
||||
- **`useSettingsBeforeUnload`** is mounted **once** in the settings shell
|
||||
(`settings/[section]/settings.tsx`) — never add a per-page `beforeunload`.
|
||||
- **Dirty *computation* stays local** (shapes differ: field-compare vs
|
||||
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
|
||||
`const`/`useMemo`), never store it in `useState`.
|
||||
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
|
||||
**unconditionally, before every early-return gate** (entitlement / loading /
|
||||
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
|
||||
skipped on gated renders and crashes.
|
||||
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
|
||||
guards real `router.push` navigation + browser Back via a history sentinel);
|
||||
it already shares `UnsavedChangesModal`, so copy stays unified.
|
||||
|
||||
## Detail sub-views
|
||||
|
||||
A drill-down view reached from a list row (selected MCP server, workflow MCP
|
||||
server, permission group, retention policy) renders through
|
||||
`SettingsPanel` like a list page: pass `back={{ text, icon: ArrowLeft, onSelect }}`
|
||||
for the left back chip, `title` (the entity name), and the header `actions`, then
|
||||
render the body. Do NOT hand-roll a shell or header bar; a tab bar renders as the
|
||||
first body child. Gate/early-return states (not-entitled, loading, upgrade
|
||||
prompts) stay as-is.
|
||||
|
||||
The route-based credential detail (`settings/secrets/[credentialId]`) is the lone
|
||||
exception — it lives outside `[section]` and keeps its own `CredentialDetailLayout`.
|
||||
|
||||
## Audit checklist
|
||||
|
||||
A settings page is design-system-clean when:
|
||||
|
||||
- [ ] Its main return is a `<SettingsPanel>` (or `<>…<SettingsPanel>…</>` with modal siblings) — no hand-rolled shell/header/scroll/column.
|
||||
- [ ] It renders **no** hand-rolled `<h1>`/description title block — the title comes from nav metadata.
|
||||
- [ ] Header chips are in `actions`; a standalone search is in the `search` prop.
|
||||
- [ ] Its `NavigationItem` has an accurate, consistent-length `description`.
|
||||
- [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional).
|
||||
- [ ] If it has editable state: Save/Discard go through `SaveDiscardActions`, dirty is wired via `useSettingsUnsavedGuard` (called before any early-return gate), and there is **no** hand-rolled Save button / `beforeunload` / "Unsaved changes" modal.
|
||||
- [ ] No business logic, handlers, or conditional rendering changed by the migration.
|
||||
- [ ] No literal `text-[Npx]` classes — named scale tokens only (see "Text-scale tokens" above).
|
||||
- [ ] `tsc`, `biome`, and the page's tests pass.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/store.ts"
|
||||
- "apps/sim/**/stores/**/*.ts"
|
||||
---
|
||||
|
||||
# Zustand Store Patterns
|
||||
|
||||
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
||||
|
||||
## Basic Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { FeatureState } from '@/stores/feature/types'
|
||||
|
||||
const initialState = { items: [] as Item[], activeId: null as string | null }
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ name: 'feature-store' }
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Persisted Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
width: 300,
|
||||
setWidth: (width) => set({ width }),
|
||||
_hasHydrated: false,
|
||||
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||
}),
|
||||
{
|
||||
name: 'feature-state',
|
||||
partialize: (state) => ({ width: state.width }),
|
||||
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Use `devtools` middleware (named stores)
|
||||
2. Use `persist` only when data should survive reload
|
||||
3. `persist` MUST use `partialize` with an explicit whitelist of the durable fields. Exclude transient flags (`isResizing`, drag/hover state) and `_hasHydrated` from the whitelist, and never spread the whole state (`{ ...state }`) — it leaks actions and transient state into storage
|
||||
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
||||
5. Immutable updates only
|
||||
6. `set((state) => ...)` when depending on previous state
|
||||
7. Provide `reset()` action
|
||||
|
||||
## Outside React
|
||||
|
||||
```typescript
|
||||
const items = useFeatureStore.getState().items
|
||||
useFeatureStore.setState({ items: newItems })
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
- "apps/sim/**/*.css"
|
||||
---
|
||||
|
||||
# Styling Rules
|
||||
|
||||
## Tailwind
|
||||
|
||||
1. **No inline styles** - Use Tailwind classes
|
||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
||||
4. **Transitions** - `transition-colors` for interactive states
|
||||
|
||||
## Conditional Classes
|
||||
|
||||
```typescript
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
<div className={cn(
|
||||
'base-classes',
|
||||
isActive && 'active-classes',
|
||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
||||
)} />
|
||||
```
|
||||
|
||||
## CSS Variables
|
||||
|
||||
For dynamic values (widths, heights) synced with stores:
|
||||
|
||||
```typescript
|
||||
// In store
|
||||
setWidth: (width) => {
|
||||
set({ width })
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||
}
|
||||
|
||||
// In component
|
||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||
```
|
||||
|
||||
## Text Scale
|
||||
|
||||
Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px. `text-sm` is Tailwind default 14px. Field titles use `text-small` (13px); hints/errors use `text-caption` (12px).
|
||||
|
||||
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; borders `--border-1` (fields) / `--border` (dividers); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
|
||||
|
||||
## Chip Components (consumer usage)
|
||||
|
||||
`ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `apps/sim/components/emcn/components/chip/chip-chrome.ts` (all tokens are re-exported from the `@sim/emcn` barrel — no subpath import needed) — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings.
|
||||
|
||||
### Props over className
|
||||
|
||||
- **Errors** → `error` prop. Never `className={cn(err && 'border-[var(--text-error)]')}`.
|
||||
- **Leading icon** → `icon` prop (rendered 14px in `--text-icon`).
|
||||
- **Trailing buttons** (reveal/copy/fetch) → `endAdornment`.
|
||||
- **Inner-input styling** (e.g. `font-mono`, number-spinner reset) → `inputClassName` (ChipInput only). See `app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx:117`.
|
||||
- **`ChipModalField` controls take NO className.** Pass `title`/`value`/`onChange`/`error`/`hint`/`required`/`flush`. The field owns label, control, and error/hint rendering. See `app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx:170`.
|
||||
|
||||
### What className MAY carry
|
||||
|
||||
Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins, `truncate`. Example: `<ChipInput icon={Search} className='min-w-0 flex-1' .../>` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it.
|
||||
|
||||
### Form / chip-modal layout rhythm
|
||||
|
||||
- **Field row** = `ChipModalField`: label↔control `gap-[9px]`, field gutter `px-2` (`px-0` when `flush`). Title = `Label` at `text-small` (13px), muted, normal weight; hint/error at `text-caption` (12px).
|
||||
- **Modal body** (`ChipModalBody`): `gap-4` between fields, padding `px-2 pt-4 pb-4.5`.
|
||||
- **Header/footer**: horizontal gutter `px-4` (header `pt-3`; footer `px-4 pt-2 pb-2`, tinted bar).
|
||||
- **Every body field MUST be a `ChipModalField`** — NEVER hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). WHY: body `px-2` + field `px-2` = effective `px-4`, exactly matching the `px-4` header/footer. A hand-rolled row skips the field gutter, sits at `px-2`, and is visibly misaligned (this bug shipped in the scheduled-tasks "Create new scheduled task" modal). Inline errors go through the `error` prop, not a hand-rolled `<p>`.
|
||||
- **Uncovered controls** (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX) → `ChipModalField type='custom'` with a `title`. It still applies the `px-2` gutter and renders the canonical `Label`, so it stays aligned. Never drop such a control into a raw `<div>`, and never add a body-level wrapper `<div>` with a custom `gap-*` that fights `gap-4`.
|
||||
- **Page section rhythm** (integrations/skills/settings): muted `text-small` label + `mt-[9px] mb-3 h-px bg-[var(--border)]` divider, sections stacked `gap-7`. Reuse `SettingsSection` (`app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx`) rather than re-deriving it.
|
||||
|
||||
When a standalone labeled field outside a `ChipModal` needs the same look (e.g. `SkillImport`), match the field rhythm by hand: `flex flex-col gap-[9px]`, muted label, `ChipInput`/`ChipTextarea` control, `text-caption` error below.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
|
||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||
2. **Props interface** - Always define for components
|
||||
3. **Const assertions** - `as const` for constant objects/arrays
|
||||
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||
5. **Type imports** - `import type { X }` for type-only imports
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
const handleClick = (e: any) => {}
|
||||
|
||||
// ✓ Good
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||
```
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/app/**/*.tsx"
|
||||
- "apps/sim/app/**/*.ts"
|
||||
- "apps/sim/app/**/search-params.ts"
|
||||
---
|
||||
|
||||
# URL / Query-Param State (nuqs)
|
||||
|
||||
URL query state is managed with [`nuqs`](https://nuqs.dev). The `NuqsAdapter` is wired once in `apps/sim/app/layout.tsx` — do not add another. This rule is the source of truth for *what* belongs in the URL and *how* to wire it.
|
||||
|
||||
## Decision framework — where does this state live?
|
||||
|
||||
Pick exactly one home for each piece of state:
|
||||
|
||||
- **React Query** → server/remote data. Unchanged; see `.claude/rules/sim-queries.md`.
|
||||
- **URL params (nuqs)** → client view-state worth putting in a link: active tab/panel, selected entity id, filters, search query, pagination, view mode (list/grid), an open "view" drawer/modal that represents a destination.
|
||||
- **Zustand** → cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, or socket-synced (canvas pan/zoom, cursor, drag state, resize widths, unsaved buffers, live collaborative selection).
|
||||
- **`useState`** → purely local, single-component UI.
|
||||
|
||||
Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL.
|
||||
|
||||
### When to use what (decision table)
|
||||
|
||||
| Home | Trigger | Example |
|
||||
| --- | --- | --- |
|
||||
| **URL (nuqs)** | Client view-state worth a link: tab, filter, search, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination | `?tab=licenses`, `?category=Communication`, `?page=3`, `?skillId=abc` |
|
||||
| **React Query** | Server/remote data fetched from an endpoint | `useMcpServers(workspaceId)`, `useSkills(workspaceId)` |
|
||||
| **Zustand** | Cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, socket-synced | canvas pan/zoom, live cursor, drag state, resize widths, unsaved buffers |
|
||||
| **`useState`** | Purely local single-component UI; also the snappy mirror of a debounced URL search | a hover flag, a transient dialog target, the live text of a debounced search box |
|
||||
|
||||
## Anti-patterns (forbidden)
|
||||
|
||||
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
|
||||
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
|
||||
- `window.history.replaceState`/`pushState` to mutate a param.
|
||||
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
|
||||
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
|
||||
- `import { z } from 'zod'` in client code for param validation — use nuqs parsers (`parseAsString`, `parseAsInteger`, `parseAsBoolean`, `parseAsStringLiteral`, `parseAsArrayOf`) or a custom `createParser`.
|
||||
|
||||
These reads/mutations are **not** anti-patterns and stay as-is:
|
||||
|
||||
- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
|
||||
- **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
|
||||
- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`.
|
||||
|
||||
## Per-feature `search-params.ts` — single source of truth
|
||||
|
||||
Co-locate a `search-params.ts` next to the feature. Export the parser map (and shared options). Both the client (`useQueryStates`/`useQueryState`) and any server component (`createSearchParamsCache` from `nuqs/server`) import from this one file. Import parsers from `nuqs/server` so the module is safe to import in both client and server contexts.
|
||||
|
||||
Conventions:
|
||||
|
||||
- `.withDefault(...)` on every parser so reads are non-null.
|
||||
- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn.
|
||||
- Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`.
|
||||
- `shallow: false` **only** when a Server Component / loader must re-read the param.
|
||||
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one.
|
||||
- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers).
|
||||
- A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq` — `clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`.
|
||||
|
||||
### Example — grouped filters (single source of truth)
|
||||
|
||||
```typescript
|
||||
// apps/sim/app/workspace/[workspaceId]/things/search-params.ts
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
|
||||
const VIEW_MODES = ['list', 'grid'] as const
|
||||
|
||||
export const thingsParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
view: parseAsStringLiteral(VIEW_MODES).withDefault('list'),
|
||||
} as const
|
||||
|
||||
/** Clean URLs, no back-stack churn for filter changes. */
|
||||
export const thingsUrlKeys = {
|
||||
history: 'replace',
|
||||
shallow: true,
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
```
|
||||
|
||||
### Client — `useQueryStates` (grouped) / `useQueryState` (single)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { thingsParsers, thingsUrlKeys } from '@/app/workspace/[workspaceId]/things/search-params'
|
||||
|
||||
export function useThingFilters() {
|
||||
const [filters, setFilters] = useQueryStates(thingsParsers, thingsUrlKeys)
|
||||
// filters.search / filters.tags / filters.view are non-null (defaults applied)
|
||||
// setFilters({ view: 'grid' }) — pass null to clear a single key back to default
|
||||
return { filters, setFilters }
|
||||
}
|
||||
```
|
||||
|
||||
For a single param, use `useQueryState(key, parser)`:
|
||||
|
||||
```typescript
|
||||
const [serverId, setServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser)
|
||||
```
|
||||
|
||||
### Server — `createSearchParamsCache`
|
||||
|
||||
When a Server Component or loader must read a param, build a cache from the **same** parser map:
|
||||
|
||||
```typescript
|
||||
// in a server component / page.tsx
|
||||
import { createSearchParamsCache } from 'nuqs/server'
|
||||
import { thingsParsers } from '@/app/workspace/[workspaceId]/things/search-params'
|
||||
|
||||
const thingsCache = createSearchParamsCache(thingsParsers)
|
||||
|
||||
export default async function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const { search, view } = await thingsCache.parse(await searchParams)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If a client param must be re-read server-side after a change, set `shallow: false` on the write.
|
||||
|
||||
## Suspense boundary
|
||||
|
||||
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`.
|
||||
|
||||
## Debounced text inputs
|
||||
|
||||
Use nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options) — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect + a ref-guarded URL→local reconcile effect. The hook's returned value updates instantly (so the input is controlled directly by the nuqs value and stays snappy); only the *URL write* is debounced. Back/forward and deep links flow back natively because the input reads the nuqs value — no reconcile effect needed.
|
||||
|
||||
- **Standalone single search param** (`useQueryState`): put `limitUrlUpdates: debounce(300)` in the param's options.
|
||||
- **Search inside a grouped `useQueryStates`**: keep the group's immediate writes for the discrete filters; pass the option **per call** only on the search setter, never on the whole group:
|
||||
|
||||
```typescript
|
||||
import { debounce } from 'nuqs'
|
||||
|
||||
const setSearch = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
// Immediate update when clearing so the param drops out without lingering.
|
||||
setFilters({ search: next }, next === null ? undefined : { limitUrlUpdates: debounce(300) })
|
||||
},
|
||||
[setFilters]
|
||||
)
|
||||
```
|
||||
|
||||
- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, 300)`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly.
|
||||
- Preserve `.trim()` handling, `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. Import `debounce` from `nuqs` (client) — not `nuqs/server`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations/recently-deleted (cheap in-memory filter, instant value), and tables (filter stays debounced).
|
||||
|
||||
## Sort convention (`sort` + `dir`)
|
||||
|
||||
Sortable lists use **two scalar params**, never a serialized `{column,direction}` object:
|
||||
|
||||
```typescript
|
||||
const SORT_COLUMNS = ['name', 'created', 'updated'] as const
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
export const thingsParsers = {
|
||||
sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'),
|
||||
} as const
|
||||
```
|
||||
|
||||
Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns.
|
||||
|
||||
## Dates in the URL (date-only params)
|
||||
|
||||
A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters.
|
||||
|
||||
**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).
|
||||
|
||||
When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/hooks/use-calendar.ts`.
|
||||
|
||||
## Selected-entity deep-link (store the id, derive the object)
|
||||
|
||||
To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL:
|
||||
|
||||
```typescript
|
||||
const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
|
||||
...skillIdParam.parser,
|
||||
history: 'push', // opening an entity is a destination; "back" closes it
|
||||
clearOnDefault: true,
|
||||
})
|
||||
// Derive — do not duplicate into useState or sync with an effect:
|
||||
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
|
||||
```
|
||||
|
||||
Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`.
|
||||
|
||||
## Read-then-strip deep links
|
||||
|
||||
For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`.
|
||||
|
||||
## Workflow editor carve-out — what must NOT go in the URL
|
||||
|
||||
The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/socket-synced via `socket-provider.tsx`. Its view-state is intentionally store-backed (Zustand), not URL-backed. Do **not** move the following into the URL:
|
||||
|
||||
- **Live cursor** and **broadcast live selection** (presence; emitted over the socket, throttled).
|
||||
- **Pan / zoom / viewport** (ReactFlow-owned, continuous, not persisted).
|
||||
- **Drag state** and **resize widths/heights** (panel/terminal/sidebar — high-frequency, persisted as local preferences).
|
||||
- **Ephemeral diff staging** (`hasActiveDiff`, `baselineWorkflow`, `diffAnalysis`).
|
||||
|
||||
Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery:
|
||||
|
||||
- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load.
|
||||
- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep.
|
||||
|
||||
Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas.
|
||||
|
||||
## Docs
|
||||
|
||||
- Adapters (App Router `NuqsAdapter`): https://nuqs.dev/docs/adapters
|
||||
- Parsers & options (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`, `withDefault`, `history`, `shallow`, `clearOnDefault`): https://nuqs.dev/docs/parsers and https://nuqs.dev/docs/options
|
||||
- Server-side reads (`createSearchParamsCache`): https://nuqs.dev/docs/server-side
|
||||
Reference in New Issue
Block a user