diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 378c9b68799..091a8307c31 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -993,11 +993,15 @@ After adding or changing one, run: ```bash bun run scripts/generate-docs.ts bun run integration-catalog:check +bun run docs:check ``` The catalog check independently derives deployment metadata from the executable block registry and -compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated -diff and keep only intentional changes. +compares it with the committed `apps/sim/lib/integrations/integrations.json`. `docs:check` re-renders +every generated docs artifact in memory and fails on any committed file that differs — it runs in CI +via `check:audits`, so commit the full generator output. If the generator also trues up pages an +earlier PR left stale, commit that catch-up too; reverting it as "unrelated drift" makes `docs:check` +fail. ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -1018,6 +1022,7 @@ diff and keep only intentional changes. - [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts - [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes - [ ] `bun run integration-catalog:check` passes +- [ ] `bun run docs:check` passes (CI gate — fails on any stale generated docs page) - [ ] If icon missing: asked user to provide SVG - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread - [ ] Optional/rarely-used fields set to `mode: 'advanced'` diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 3369828d6f2..22ed2ef4195 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -561,6 +561,7 @@ Run the documentation generator: ```bash bun run scripts/generate-docs.ts bun run integration-catalog:check +bun run docs:check ``` This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`). @@ -651,6 +652,9 @@ If creating V2 versions (API-aligned outputs): - [ ] Verified docs file created - [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change - [ ] `bun run integration-catalog:check` passes +- [ ] `bun run docs:check` passes — CI fails on stale generated docs, so commit the full generator + output, including catch-up regeneration for pages another PR left stale (never revert it as + "unrelated drift") ### Final Validation (Required) - [ ] Read every tool file and cross-referenced inputs/outputs against the API docs diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 734c03bcd9c..6ae100ce86b 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -475,6 +475,9 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet` - [ ] Index.ts exports all tools and re-exports types (`export * from './types'`) - [ ] Tools registered in `tools/registry.ts` - [ ] `bun run tool-metadata:generate` run and the regenerated artifacts committed +- [ ] `bun run scripts/generate-docs.ts` run and the refreshed docs committed — the integration's + docs page is rendered from each tool's description, params, and outputs, and CI's + `bun run docs:check` fails on stale pages - [ ] Block wired: `tools.access`, dropdown options, subBlocks, `tools.config`, outputs, inputs - [ ] Model, durable-storage, and internal-execution boundaries use the shared provenance mechanisms only where a concrete Sim `{{...}}` resolution path requires them diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 2720d94f99d..3175d46e1c8 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -511,3 +511,6 @@ Add to `helm/sim/values.yaml` under the existing polling cron jobs: - [ ] `bun run type-check` passes - [ ] Manually verify output keys match trigger `outputs` keys - [ ] Trigger UI shows correctly in the block +- [ ] Ran `bun run scripts/generate-docs.ts` and committed the refreshed pages — trigger sections + render into the owning integration's docs page, and CI's `bun run docs:check` fails on stale + pages diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index da5ac1dd984..68ed9ee7620 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -388,6 +388,7 @@ Several files are generated from tool and block definitions. Editing a tool or b bun run tool-metadata:generate # repo root — apps/sim/tools/generated/* bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons bun run integration-catalog:check # registry ↔ committed deployment metadata drift +bun run docs:check # committed docs ↔ what the generator renders today ``` - **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it. @@ -395,8 +396,17 @@ bun run integration-catalog:check # registry ↔ committed deployment metadat - **`integration-catalog:check`** — loads the executable block registry, derives visible integration deployment fields, and compares them with the committed catalog. It catches missing/unexpected entries and stale auth/service IDs without loading the executable registry in client code. - -**Always diff the regen output before committing.** These generators rewrite every file they own, so they will also sweep in unrelated drift that accumulated on the base branch — pages losing sections, unrelated icons appearing. Keep only the hunks belonging to the integration under validation and `git checkout --` the rest, otherwise an unrelated doc regression rides along in the PR. Verify no page was silently dropped by comparing the directory listing before and after. +- **`docs:check`** — check mode of `generate-docs.ts`: renders every generated docs artifact in + memory and fails listing any committed file that differs. Runs in CI via `check:audits`. + +**Always diff the regen output before committing — but commit all of it.** These generators rewrite +every file they own, so they also true up drift that accumulated on the base branch (pages whose +source changed without a regen). That catch-up is correct output, not a regression: `docs:check` +fails CI on any page left stale, so reverting swept-in hunks with `git checkout --` reintroduces the +failure. Review the diff to confirm each hunk is explained by a real source change (yours or an +upstream PR that skipped regeneration), and investigate anything that looks like content loss — a +page losing a section usually means its source block moved or a generator input broke, not that the +hunk should be reverted. If an icon changed, `apps/sim/components/icons.tsx` is the source of truth and `apps/docs/components/icons.tsx` is its generated mirror — they must end up byte-identical for that component. @@ -408,9 +418,10 @@ After fixing, confirm: 3. The integration's tests pass, and any test you added actually fails without its fix (revert it once and watch it go red) 4. Derived artifacts regenerated and their diffs reviewed (see above) 5. `bun run integration-catalog:check` passes -6. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes -7. Re-read all modified files to verify fixes are correct -8. Any remaining unknown response schemas were explicitly reported to the user instead of guessed +6. `bun run docs:check` passes +7. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes +8. Re-read all modified files to verify fixes are correct +9. Any remaining unknown response schemas were explicitly reported to the user instead of guessed ## Checklist Summary @@ -439,7 +450,7 @@ After fixing, confirm: - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run tool-metadata:generate` if any tool outputs/params changed, and confirmed `bun run tool-metadata:check` passes -- [ ] Ran `bun run generate-docs` if any block metadata changed, and reverted unrelated drift the generator swept in +- [ ] Ran `bun run generate-docs` if any block metadata changed, and committed the full generated diff — including stale-page catch-up for other integrations (`bun run docs:check` fails CI on reverted generator output) - [ ] Ran `bun run lint` after fixes - [ ] Verified TypeScript compiles clean - [ ] Verified added tests fail without their fix diff --git a/.claude/commands/add-block-preview.md b/.claude/commands/add-block-preview.md deleted file mode 100644 index 8345170767f..00000000000 --- a/.claude/commands/add-block-preview.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Gate a block's visibility — ship an unreleased block as a preview (hidden until revealed via AppConfig/env), reveal it to admins/orgs, GA it, or kill-switch a shipped block -argument-hint: ---- - -# Add Block Preview Skill - -You manage **block visibility gating** in Sim — hiding blocks from every discovery surface (toolbar, cmd+K search, copilot @-mentions, agent tool picker, mothership VFS/metadata/tools, Access Control list, public docs/catalog) while **never** gating execution of already-placed instances. - -## The model - -Three levers, evaluated in `apps/sim/lib/core/config/block-visibility.ts` and folded into the registry accessors (`apps/sim/blocks/registry.ts`): - -1. **`preview: true`** on the `BlockConfig` (static, in code) — the block is default-hidden EVERYWHERE (hosted, self-hosted, dev, SSR) until revealed. Fail-closed. -2. **The hosted `block-visibility` AppConfig document** — per-block rule keyed by the existing block type: - - ```jsonc - { - "": { - "enabled": false, // required. true = GA (visible to everyone) - "orgIds": ["org_..."], // optional allowlist clauses (any match reveals) - "userIds": ["user_..."], - "adminEnabled": true // platform admins (user.role === 'admin') - } - } - ``` - -3. **`PREVIEW_BLOCKS` env** (comma-separated block types) — the off-AppConfig reveal path for self-hosters and local dev. - -A revealed block that is not globally GA (`enabled !== true`, or env-revealed) renders with a **" (Preview)"** name suffix on discovery surfaces. `getBlock()` stays pure, so placed instances keep their canonical name and always execute. - -## Lifecycle of a preview block - -1. **Author** the block normally (`/add-block` etc.) and set `preview: true` on its `BlockConfig`. **Ship no `BlockMeta` and no docs until GA** — `check-block-registry` deliberately skips preview blocks in meta coverage, and `generate-docs` skips them at every gate. -2. **Local dev:** set `PREVIEW_BLOCKS=` in your env to see it (with the suffix). -3. **Merge/deploy.** The block's code is live everywhere but visible nowhere — no AppConfig rule exists and self-hosters have no env entry. -4. **Hosted preview:** add a rule to the `block-visibility` AppConfig document and start a deployment (no code deploy): - - Admins only: `{ "enabled": false, "adminEnabled": true }` - - Design-partner org: `{ "enabled": false, "orgIds": ["org_123"] }` - - GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch. - - Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim--fast` strategy (see the infra README). -5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm). - -## Kill switch (shipped blocks) - -To pull an already-GA block from discovery surfaces on hosted (incident, deprecation): add `{ "": { "enabled": false } }` to the document. Allowlist clauses can carve out exceptions. **Execution is NOT stopped** — workflows already using the block keep running; the kill switch only prevents new placement/discovery. - -## Invariants (do not violate) - -- **Execution is never gated.** The executor, serializer, drop-naming, and `isBlockTypeAccessControlExempt` resolve via pure `getBlock`. Do not add visibility checks to execution paths. -- **Clone-not-remove:** gated blocks stay in `getAllBlocks()` output as clones with `hideFromToolbar: true` — `.find`-by-type consumers rely on this. Never filter them out. -- **Keys are registry block types.** Never `custom_block_*` (parse drops them — custom blocks have their own enabled/disabled lifecycle). -- **The shared hidden-predicate is `isHiddenUnder`** (`apps/sim/blocks/visibility/context.ts`). Never restate the preview/disabled rule inline at a new consumer. -- **Process-global caches stay ungated.** `getStaticComponentFiles` (VFS) and `getExposedIntegrationTools` build the ungated universe; per-viewer filtering happens at stamp/consumer time. Never move gating into a shared builder. -- Gating is **surface hiding, not secrecy** — the full config ships in the client JS bundle. Anything truly secret cannot be a registered block. - -## Tests - -Evaluation semantics: `apps/sim/lib/core/config/block-visibility.test.ts`. Registry projection: `apps/sim/blocks/visibility/visibility.test.ts`. When gating behavior changes, extend those — mock `isPlatformAdmin` for the admin clause; use the local `withAppConfig` harness. diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md deleted file mode 100644 index 1f9a8554af8..00000000000 --- a/.claude/commands/add-block.md +++ /dev/null @@ -1,1042 +0,0 @@ ---- -description: Create or update a Sim integration block with correct subBlocks, conditions, dependsOn, modes, canonicalParamId usage, outputs, and tool wiring. Use when working on `apps/sim/blocks/blocks/{service}.ts` or aligning a block with its tools. -argument-hint: ---- - -# Add Block Skill - -You are an expert at creating block configurations for Sim. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns. - -## Your Task - -When the user asks you to create a block: -1. Create the block file in `apps/sim/blocks/blocks/{service}.ts` -2. Configure all subBlocks with proper types, conditions, and dependencies -3. Wire up tools correctly - -## Hard Rule: No Guessed Tool Outputs - -Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs. - -- Do NOT invent block outputs for undocumented tool responses -- Do NOT describe unknown JSON shapes as if they were confirmed -- Do NOT wire fields into the block just because they seem likely to exist - -If the tool outputs are not known, do one of these instead: -1. Ask the user for sample tool responses -2. Ask the user for test credentials so the tool responses can be verified -3. Limit the block to operations whose outputs are documented -4. Leave uncertain outputs out and explicitly tell the user what remains unknown - -## Block Configuration Structure - -```typescript -import { {ServiceName}Icon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const {ServiceName}Block: BlockConfig = { - type: '{service}', // snake_case identifier - name: '{Service Name}', // Human readable - description: 'Brief description', // One sentence - longDescription: 'Detailed description for docs', - docsLink: 'https://docs.sim.ai/integrations/{service}', - category: 'tools', // 'tools' | 'blocks' | 'triggers' - integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) - tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) - bgColor: '#HEXCOLOR', // Brand color - icon: {ServiceName}Icon, - - // Auth mode - authMode: AuthMode.OAuth, // or AuthMode.ApiKey - - // Card summary sentences — see "Canvas Sentences" below - canvasPresentation: { - defaultTitle: '{Default Operation}', - sentences: { byOperation: { /* one per operation dropdown option id */ } }, - }, - - subBlocks: [ - // Define all UI fields here - ], - - tools: { - access: ['tool_id_1', 'tool_id_2'], // Array of tool IDs this block can use - config: { - tool: (params) => `{service}_${params.operation}`, // Tool selector function - params: (params) => ({ - // Transform subBlock values to tool params - }), - }, - }, - - inputs: { - // Optional: define expected inputs from other blocks - }, - - outputs: { - // Define outputs available to downstream blocks - }, -} -``` - -## SubBlock Types Reference - -**Critical:** Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions. - -### Text Inputs -```typescript -// Single-line input -{ id: 'field', title: 'Label', type: 'short-input', placeholder: '...' } - -// Multi-line input -{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 } - -// Password input -{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true } -``` - -### Selection Inputs -```typescript -// Dropdown (static options) -{ - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Create', id: 'create' }, - { label: 'Update', id: 'update' }, - ], - value: () => 'create', // Default value function -} - -// Combobox (searchable dropdown) -{ - id: 'field', - title: 'Label', - type: 'combobox', - options: [...], - searchable: true, -} -``` - -### Code/JSON Inputs -```typescript -{ - id: 'code', - title: 'Code', - type: 'code', - language: 'javascript', // 'javascript' | 'json' | 'python' - placeholder: '// Enter code...', -} -``` - -### OAuth/Credentials -```typescript -{ - id: 'credential', - title: 'Account', - type: 'oauth-input', - serviceId: '{service}', // Must match OAuth provider service key - requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils - placeholder: 'Select account', - required: true, -} -``` - -**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`. - -**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. - -**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: - -```typescript -{ - id: 'credential', - title: 'Account', - type: 'oauth-input', - serviceId: '{service}', - requiredScopes: getScopesForService('{service}'), - credentialKind: 'any', // omit | 'service-account' | 'any' -} -``` - -- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. -- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. -- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). - -Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. - -### OAuth deployment availability (required for integration blocks) - -A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is -projected into `apps/sim/lib/integrations/integrations.json`, then resolved through -`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`. - -When adding or changing an OAuth integration block: - -1. Keep exactly one distinct OAuth `serviceId` across the block's `oauth-input` subBlocks. -2. Confirm that service ID resolves to an entry in `OAUTH_CLIENT_CAPABILITIES`. Google and - Microsoft service IDs intentionally share their provider-level capability; do not add duplicate - entries for those aliases. -3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure - every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add - the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in - `scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against - the runtime field list; do not infer secrecy from the field name. -4. If the canonical OAuth service declares `serviceAccountProviderId`, keep - `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in - `apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set - `deploymentRequirement` only when the service-account path is preview-gated or depends on the - OAuth client fields; otherwise omit it. - -Missing capability metadata is a runtime configuration error, not a reason to make the integration -silently available. - -### Selectors (with dynamic options) -```typescript -// Channel selector (Slack, Discord, etc.) -{ - id: 'channel', - title: 'Channel', - type: 'channel-selector', - serviceId: '{service}', - placeholder: 'Select channel', - dependsOn: ['credential'], -} - -// Project selector (Jira, etc.) -{ - id: 'project', - title: 'Project', - type: 'project-selector', - serviceId: '{service}', - dependsOn: ['credential'], -} - -// File selector (Google Drive, etc.) -{ - id: 'file', - title: 'File', - type: 'file-selector', - serviceId: '{service}', - mimeType: 'application/pdf', - dependsOn: ['credential'], -} - -// User selector -{ - id: 'user', - title: 'User', - type: 'user-selector', - serviceId: '{service}', - dependsOn: ['credential'], -} -``` - -### Other Types -```typescript -// Switch/toggle -{ id: 'enabled', type: 'switch' } - -// Slider -{ id: 'temperature', title: 'Temperature', type: 'slider', min: 0, max: 2, step: 0.1 } - -// Table (key-value pairs) -{ id: 'headers', title: 'Headers', type: 'table', columns: ['Key', 'Value'] } - -// File upload -{ - id: 'files', - title: 'Attachments', - type: 'file-upload', - multiple: true, - acceptedTypes: 'image/*,application/pdf', -} -``` - -## File Input Handling - -When your block accepts file uploads, use the basic/advanced mode pattern with `normalizeFileInput`. - -### Basic/Advanced File Pattern - -```typescript -// Basic mode: Visual file upload -{ - id: 'uploadFile', - title: 'File', - type: 'file-upload', - canonicalParamId: 'file', // Both map to 'file' param - placeholder: 'Upload file', - mode: 'basic', - multiple: false, - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -// Advanced mode: Reference from other blocks -{ - id: 'fileRef', - title: 'File', - type: 'short-input', - canonicalParamId: 'file', // Both map to 'file' param - placeholder: 'Reference file (e.g., {{file_block.output}})', - mode: 'advanced', - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -``` - -**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to -a file from a previous block. Gmail attachments are the reference implementation -(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). - -Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a -path). A subblock whose meaning changes based on what the string looks like is impossible to reason -about, forces the params function to sniff the value, and makes the field's type meaningless. Give -each alternative its own subblock outside the pair: - -```typescript -// ✓ Good — the pair is "a file"; other sources are their own fields -{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, -{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, -{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept -{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept - -// ✗ Bad — one field meaning three things, resolved by guessing -{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', - placeholder: 'File reference, media ID, or public URL' }, -``` - -When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce -"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the -other paths ever get a chance to supply the value. - -**Critical constraints:** -- `canonicalParamId` must NOT match any subblock's `id` in the same block -- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by - `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations - that each need a file pair need two distinct `canonicalParamId` values. -- All members of a group must share the same `required` status - -### Normalizing File Input in tools.config - -Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at -serialization, before variable resolution, so a `` file reference is not yet a value -there. - -```typescript -import { normalizeFileInput } from '@/blocks/utils' - -tools: { - access: ['service_upload'], - config: { - tool: (params) => `service_${params.operation}`, - params: (params) => { - // Read the CANONICAL id, not the subblock ids - const { file: fileParam, ...rest } = params - const file = normalizeFileInput(fileParam, { single: true }) - return { - ...rest, - ...(file ? { file } : {}), - } - }, - }, -} -``` - -**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, -but it is not what the params function receives. `extractBlockParams` -(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: - -```typescript -const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) -sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted -if (chosen !== undefined) params[group.canonicalId] = chosen -``` - -So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), -`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a -subblock id there yields `undefined` and silently sends no file. - -Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode -and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can -never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution -produces. - -Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so -omitting a key from the returned object does not strip it from what the tool receives. Tools simply -ignore params they do not declare. - -### File Input Types in `inputs` - -Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: - -```typescript -inputs: { - file: { type: 'json', description: 'File to upload (UserFile or reference)' }, - // Legacy field for backwards compatibility - fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, -} -``` - -### Multiple Files - -For multiple file uploads: - -```typescript -{ - id: 'attachments', - title: 'Attachments', - type: 'file-upload', - multiple: true, // Allow multiple files - maxSize: 25, // Max size in MB per file - acceptedTypes: 'image/*,application/pdf,.doc,.docx', -} - -// In tools.config: -const normalizedFiles = normalizeFileInput( - params.attachments || params.attachmentRefs, - // No { single: true } - returns array -) -if (normalizedFiles) { - params.files = normalizedFiles -} -``` - -## Condition Syntax - -Controls when a field is shown based on other field values. - -### Simple Condition -```typescript -condition: { field: 'operation', value: 'create' } -// Shows when operation === 'create' -``` - -### Multiple Values (OR) -```typescript -condition: { field: 'operation', value: ['create', 'update'] } -// Shows when operation is 'create' OR 'update' -``` - -### Negation -```typescript -condition: { field: 'operation', value: 'delete', not: true } -// Shows when operation !== 'delete' -``` - -### Compound (AND) -```typescript -condition: { - field: 'operation', - value: 'send', - and: { - field: 'type', - value: 'dm', - not: true, - } -} -// Shows when operation === 'send' AND type !== 'dm' -``` - -### Complex Example -```typescript -condition: { - field: 'operation', - value: ['list', 'search'], - not: true, - and: { - field: 'authMethod', - value: 'oauth', - } -} -// Shows when operation NOT in ['list', 'search'] AND authMethod === 'oauth' -``` - -## DependsOn Pattern - -Controls when a field is enabled and when its options are refetched. - -### Simple Array (all must be set) -```typescript -dependsOn: ['credential'] -// Enabled only when credential has a value -// Options refetch when credential changes - -dependsOn: ['credential', 'projectId'] -// Enabled only when BOTH have values -``` - -### Complex (all + any) -```typescript -dependsOn: { - all: ['authMethod'], // All must be set - any: ['credential', 'apiKey'] // At least one must be set -} -// Enabled when authMethod is set AND (credential OR apiKey is set) -``` - -## Required Pattern - -Can be boolean or condition-based. - -### Simple Boolean -```typescript -required: true -required: false -``` - -### Conditional Required -```typescript -required: { field: 'operation', value: 'create' } -// Required only when operation === 'create' - -required: { field: 'operation', value: ['create', 'update'] } -// Required when operation is 'create' OR 'update' -``` - -## Mode Pattern (Basic vs Advanced) - -Controls which UI view shows the field. - -### Mode Options -- `'basic'` - Only in basic view (default UI) -- `'advanced'` - Only in advanced view -- `'both'` - Both views (default if not specified) -- `'trigger'` - Only in trigger configuration - -### canonicalParamId Pattern - -Maps multiple UI fields to a single serialized parameter: - -```typescript -// Basic mode: Visual selector -{ - id: 'channel', - title: 'Channel', - type: 'channel-selector', - mode: 'basic', - canonicalParamId: 'channel', // Both map to 'channel' param - dependsOn: ['credential'], -} - -// Advanced mode: Manual input -{ - id: 'channelId', - title: 'Channel ID', - type: 'short-input', - mode: 'advanced', - canonicalParamId: 'channel', // Both map to 'channel' param - placeholder: 'Enter channel ID manually', -} -``` - -**How it works:** -- In basic mode: `channel` selector value → `params.channel` -- In advanced mode: `channelId` input value → `params.channel` -- The serializer consolidates based on current mode - -**Critical constraints:** -- `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts) -- A `canonicalParamId` links exactly one basic/advanced pair for a single logical parameter. Do NOT reuse the same `canonicalParamId` for different parameters, even under mutually-exclusive conditions/operations -- ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter -- Do NOT use it for any other purpose - -## WandConfig Pattern - -Enables AI-assisted field generation. - -```typescript -{ - id: 'query', - title: 'Query', - type: 'code', - language: 'json', - wandConfig: { - enabled: true, - prompt: 'Generate a query based on the user request. Return ONLY the JSON.', - placeholder: 'Describe what you want to query...', - generationType: 'json-object', // Optional: affects AI behavior - maintainHistory: true, // Optional: keeps conversation context - }, -} -``` - -### Generation Types -- `'javascript-function-body'` - JS code generation -- `'json-object'` - Raw JSON (adds "no markdown" instruction) -- `'json-schema'` - JSON Schema definitions -- `'sql-query'` - SQL statements -- `'timestamp'` - Adds current date/time context - -## Tools Configuration - -**Important:** `tools.config.tool` runs during serialization before variable resolution. Put `Number()` and other type coercions in `tools.config.params` instead, which runs at execution time after variables are resolved. - -**Preferred:** Use tool names directly as dropdown option IDs to avoid switch cases: -```typescript -// Dropdown options use tool IDs directly -options: [ - { label: 'Create', id: 'service_create' }, - { label: 'Read', id: 'service_read' }, -] - -// Tool selector just returns the operation value -tool: (params) => params.operation, -``` - -### With Parameter Transformation -```typescript -tools: { - access: ['service_action'], - config: { - tool: (params) => 'service_action', - params: (params) => ({ - id: params.resourceId, - data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data, - }), - }, -} -``` - -### V2 Versioned Tool Selector -```typescript -import { createVersionedToolSelector } from '@/blocks/utils' - -tools: { - access: [ - 'service_create_v2', - 'service_read_v2', - 'service_update_v2', - ], - config: { - tool: createVersionedToolSelector({ - baseToolSelector: (params) => `service_${params.operation}`, - suffix: '_v2', - fallbackToolId: 'service_create_v2', - }), - }, -} -``` - -## Outputs Definition - -**IMPORTANT:** Block outputs have a simpler schema than tool outputs. Block outputs do NOT support: -- `optional: true` - This is only for tool outputs -- `items` property - This is only for tool outputs with array types - -Block outputs only support: -- `type` - The data type ('string', 'number', 'boolean', 'json', 'array') -- `description` - Human readable description -- `condition` - Optional visibility condition -- `hiddenFromDisplay` - Optional flag to hide from the output display - -**Nested object/`properties` outputs are tool-output-only and will fail TypeScript at build time on block outputs.** For complex shapes use `type: 'json'` and describe the inner fields in the `description` string. - -```typescript -outputs: { - // Simple outputs - id: { type: 'string', description: 'Resource ID' }, - success: { type: 'boolean', description: 'Whether operation succeeded' }, - - // Use type: 'json' for complex objects or arrays (NOT type: 'array' with items) - items: { type: 'json', description: 'List of items' }, - metadata: { type: 'json', description: 'Response metadata' }, - - // Nested outputs (for structured data) - user: { - id: { type: 'string', description: 'User ID' }, - name: { type: 'string', description: 'User name' }, - email: { type: 'string', description: 'User email' }, - }, -} -``` - -### Typed JSON Outputs - -When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. Block outputs have no nested `properties` form — always keep the output flat and put the shape in the `description`: - -```typescript -outputs: { - // BAD: Opaque json with no info about what's inside - plan: { type: 'json', description: 'Zone plan information' }, - - // GOOD: Describe the known fields in the description - plan: { - type: 'json', - description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)', - }, -} -``` - -Nested object outputs (`plan: { id: { type: 'string' }, ... }`) are a **tool-output** feature only — `OutputFieldDefinition` for blocks does not allow them and they fail TypeScript at build time. - -If the output shape is unknown because the underlying tool response is undocumented, you MUST tell the user and stop. Unknown is not the same as variable. Never guess block outputs. - -## V2 Block Pattern - -When creating V2 blocks (alongside legacy V1): - -```typescript -// V1 Block - mark as legacy -export const ServiceBlock: BlockConfig = { - type: 'service', - name: 'Service (Legacy)', - hideFromToolbar: true, // Hide from toolbar - // ... rest of config -} - -// V2 Block - visible, uses V2 tools -export const ServiceV2Block: BlockConfig = { - type: 'service_v2', - name: 'Service', // Clean name - hideFromToolbar: false, // Visible - subBlocks: ServiceBlock.subBlocks, // Reuse UI - tools: { - access: ServiceBlock.tools?.access?.map(id => `${id}_v2`) || [], - config: { - tool: createVersionedToolSelector({ - baseToolSelector: (params) => (ServiceBlock.tools?.config as any)?.tool(params), - suffix: '_v2', - fallbackToolId: 'service_default_v2', - }), - params: ServiceBlock.tools?.config?.params, - }, - }, - outputs: { - // Flat, API-aligned outputs (not wrapped in content/metadata) - }, -} -``` - -## Registering Blocks - -After creating the block, remind the user to register it in `apps/sim/blocks/registry-maps.ts` (the data maps live here; `registry.ts` holds only the accessor functions). Add the import and an entry to each map alphabetically: - -```typescript -import { ServiceBlock, ServiceBlockMeta } from '@/blocks/blocks/service' - -export const BLOCK_REGISTRY: Record = { - // ... existing blocks ... - service: ServiceBlock, -} - -export const BLOCK_META_REGISTRY: Record = { - // ... existing metas ... - service: ServiceBlockMeta, -} -``` - -## Complete Example - -```typescript -import { ServiceIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const ServiceBlock: BlockConfig = { - type: 'service', - name: 'Service', - description: 'Integrate with Service API', - longDescription: 'Full description for documentation...', - docsLink: 'https://docs.sim.ai/integrations/service', - category: 'tools', - integrationType: IntegrationType.DeveloperTools, - tags: ['oauth', 'api'], - bgColor: '#FF6B6B', - icon: ServiceIcon, - authMode: AuthMode.OAuth, - - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Create', id: 'create' }, - { label: 'Read', id: 'read' }, - { label: 'Update', id: 'update' }, - { label: 'Delete', id: 'delete' }, - ], - value: () => 'create', - }, - { - id: 'credential', - title: 'Service Account', - type: 'oauth-input', - serviceId: 'service', - requiredScopes: getScopesForService('service'), - placeholder: 'Select account', - required: true, - }, - { - id: 'resourceId', - title: 'Resource ID', - type: 'short-input', - placeholder: 'Enter resource ID', - condition: { field: 'operation', value: ['read', 'update', 'delete'] }, - required: { field: 'operation', value: ['read', 'update', 'delete'] }, - }, - { - id: 'name', - title: 'Name', - type: 'short-input', - placeholder: 'Resource name', - condition: { field: 'operation', value: ['create', 'update'] }, - required: { field: 'operation', value: 'create' }, - }, - ], - - tools: { - access: ['service_create', 'service_read', 'service_update', 'service_delete'], - config: { - tool: (params) => `service_${params.operation}`, - }, - }, - - outputs: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - createdAt: { type: 'string', description: 'Creation timestamp' }, - }, -} -``` - -## Connecting Blocks with Triggers - -If the service supports webhooks, connect the block to its triggers. - -```typescript -import { getTrigger } from '@/triggers' - -export const ServiceBlock: BlockConfig = { - // ... basic config ... - - triggers: { - enabled: true, - available: ['service_event_a', 'service_event_b', 'service_webhook'], - }, - - subBlocks: [ - // Tool subBlocks first... - { id: 'operation', /* ... */ }, - - // Then spread trigger subBlocks - ...getTrigger('service_event_a').subBlocks, - ...getTrigger('service_event_b').subBlocks, - ...getTrigger('service_webhook').subBlocks, - ], -} -``` - -See the `/add-trigger` skill for creating triggers. - -## Icon Requirement - -If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG: - -``` -The block is complete, but I need an icon for {Service}. -Please provide the SVG and I'll convert it to a React component. - -You can usually find this in the service's brand/press kit page, or copy it from their website. -``` - -When converting the SVG: a **monochrome** logo (single white or black mark) must -use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render -both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested -actions list) in light and dark mode; a hardcoded white/black mark goes invisible -bare on the matching background. Multi-color brand logos keep their own fills. -Verify with `bun run check:bare-icons`. - -## Advanced Mode for Optional Fields - -Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: -- Pagination tokens -- Time range filters (start/end time) -- Sort order options -- Reply settings -- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID) -- Max results / limits - -```typescript -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - placeholder: 'ISO 8601 timestamp', - condition: { field: 'operation', value: ['search', 'list'] }, - mode: 'advanced', // Rarely used, hide from basic view -} -``` - -## WandConfig for Complex Inputs - -Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience. - -```typescript -// Timestamps - use generationType: 'timestamp' to inject current date context -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', - generationType: 'timestamp', - }, -} - -// Comma-separated lists - simple prompt without generationType -{ - id: 'mediaIds', - title: 'Media IDs', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.', - }, -} -``` - -## Naming Convention - -All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase. - -## BlockMeta (Required) - -Every block file must export a `{Service}BlockMeta` alongside the block — **minimum 7 templates**. Look at existing examples in `apps/sim/blocks/blocks/` (e.g. `browser_use.ts`, `google_sheets.ts`) for the pattern. - -```typescript -import type { BlockMeta } from '@/blocks/types' - -export const {Service}BlockMeta = { - tags: ['tag1', 'tag2'], // IntegrationTag[] - url: 'https://{service}.com', // external service homepage (verify it resolves) — NOT docs.sim.ai - templates: [ - { - icon: {Service}Icon, - title: '{Service} ', // 2–5 words - prompt: 'Build a workflow that...', // specific use case, 1–3 sentences - modules: ['agent', 'workflows'], // 'agent' | 'workflows' | 'tables' | 'files' | 'scheduled' | 'knowledge-base' - category: 'operations', // 'operations' | 'marketing' | 'sales' | 'engineering' | 'productivity' | 'support' | 'popular' - tags: ['automation'], - alsoIntegrations: ['slack'], // optional — other block IDs referenced in the prompt - featured: true, // optional - }, - // ... at least 6 more - ], - skills: [ // SuggestedSkill[] — 3–5 mainstream, 2–3 niche - { - name: 'summarize-thread', // kebab-case, ≤64 chars, unique, verb-led - description: 'One line: what it does and when to use it.', // ≤1024 chars - content: - '# Summarize Thread\n\n...\n\n## Steps\n1. ...\n\n## Output\n...', // markdown - }, - // ... more - ], -} as const satisfies BlockMeta -``` - -Derive templates from the service's real use cases. Each prompt should name a concrete trigger, transformation, and output — not a generic description of what the service does. - -`skills` are curated, ready-to-add agent skills shown on the integration's detail page (users click **Add** to create them in their workspace). Two hard rules: - -- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. -- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. - -## Canvas Sentences - -Every block declares a one-line prose summary that replaces its card's field rows: - -``` -Slack ← header (already names the block) -Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips -``` - -Write one `byOperation` entry per operation dropdown option (or a single `default` -when the block has no operation dropdown). - -**The full authoring contract — voice, structure, and the two mistakes that break -cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it -before writing any.** The two failures worth repeating here, because both are -invisible at runtime: - -1. A clause naming only one member of a `canonicalParamId` pair drops the sentence - for every advanced-mode user. List all members: - `field: ['channelSelector', 'manualChannel']`. -2. A clause referencing a subblock whose `condition` excludes that operation can - never render. - -Validate before finishing: - -```bash -bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} -``` - -## Generated artifacts - -Adding a block on its own needs no **tool metadata** regeneration — a block references existing -tool IDs through `tools.access` and does not change any tool's shape. - -But if the same change also adds, edits **or removes** a tool, run `bun run tool-metadata:generate` and commit the result, or CI fails on stale artifacts. That matters here because a block's `outputs` are authored to match its tools' outputs, and the UI now reads those from the generated metadata rather than the executable registry — an unregenerated tool change makes the block's outputs disagree with what the panel renders. See `.agents/skills/tool-registry-boundary/SKILL.md`. - -A visible integration block does require the generated integration catalog and docs to be refreshed. -After adding or changing one, run: - -```bash -bun run scripts/generate-docs.ts -bun run integration-catalog:check -``` - -The catalog check independently derives deployment metadata from the executable block registry and -compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated -diff and keep only intentional changes. -## Checklist Before Finishing - -- [ ] `integrationType` is set to the correct `IntegrationType` enum value -- [ ] `tags` array includes all applicable `IntegrationTag` values -- [ ] All subBlocks have `id`, `title` (except switch), and `type` -- [ ] Conditions use correct syntax (field, value, not, and) -- [ ] DependsOn set for fields that need other values -- [ ] Required fields marked correctly (boolean or condition) -- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)` -- [ ] Every OAuth `serviceId` resolves through `resolveOAuthClientCapabilityId()` to the correct `OAUTH_CLIENT_CAPABILITIES` entry -- [ ] Any new OAuth capability fields exist in `apps/sim/lib/core/config/env.ts` -- [ ] If the OAuth service supports service accounts, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` matches its canonical `serviceAccountProviderId` and deployment requirement -- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes -- [ ] Tools.access lists all tool IDs (snake_case) -- [ ] Tools.config.tool returns correct tool ID (snake_case) -- [ ] Outputs match tool outputs -- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) -- [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts -- [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes -- [ ] `bun run integration-catalog:check` passes -- [ ] If icon missing: asked user to provide SVG -- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread -- [ ] Optional/rarely-used fields set to `mode: 'advanced'` -- [ ] Timestamps and complex inputs have `wandConfig` enabled -- [ ] Exported `{Service}BlockMeta` with at least 7 templates -- [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) -- [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) -- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage - -## Final Validation (Required) - -After creating the block, you MUST validate it against every tool it references: - -1. **Read every tool definition** that appears in `tools.access` — do not skip any -2. **For each tool, verify the block has correct:** - - SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation) - - SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings) - - `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ) - - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) -3. **Verify block outputs** cover the key fields returned by all tools -4. **Verify conditions** — each subBlock should only show for the operations that actually use it -5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` -6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs diff --git a/.claude/commands/add-column-type.md b/.claude/commands/add-column-type.md deleted file mode 100644 index 7b362016218..00000000000 --- a/.claude/commands/add-column-type.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -description: Add a new table column type to Sim — registry entry, icon, storage shape, coercion, and the behavioral hooks the grid and API read. Use when adding a value kind under `apps/sim/lib/table/column-types/`. -argument-hint: ---- - -# Adding a Table Column Type - -A column type is **one file** in `apps/sim/lib/table/column-types/` plus a registry entry. Everything that varies per type — label, icon, storage cast, coercion, validation, conversion compatibility, formatting, editor, filter operators — lives on that one object, so no consumer needs editing. - -This was not always true: adding `currency` originally took ~40 edits across 32 `switch` arms and 26 UI branches, each of which failed **silently** when missed. The registry exists to make that impossible, so the rule is absolute: **if you find yourself adding a `case 'yourtype':` anywhere outside `column-types/`, the registry is missing a field. Add the field instead.** - -## Hard Rule: the compiler tells you what to do - -Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: - -```bash -cd apps/sim && bun run type-check -``` - -You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. - -If your type owns metadata, adding its key to `TYPE_SPECIFIC_COLUMN_KEYS` produces two more legitimate errors — `FOREIGN_METADATA_VERB` in `validation.ts` (a `Record` over those keys) and the key's absence from `ColumnDefinition`. Those are the gate working, not sites to "fix". - -Any error beyond those four is a site reading a hardcoded type list that should read the registry — fix that site, don't work around it. - -## Directory Structure - -``` -apps/sim/lib/table/column-types/ -├── types.ts # ColumnTypeDefinition — the contract you implement -├── types.server.ts # ColumnTypeServerDefinition — cell migrations only -├── registry.ts # Record ← client-safe, the gate -├── registry.server.ts # Record ← adds migrations (drizzle) -├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …) -└── {type}.ts # one file per type — what you write -``` - -## Step 1: Pick the storage shape - -Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else: - -| Storage | `jsonbCast` | Notes | -|---------|-------------|-------| -| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. | -| ISO string | `'timestamptz'` | `date` does this. | -| string / bool / object | `null` | Text comparison is correct. | - -**Prefer an existing primitive over a new shape.** `currency` stores a plain number and keeps its ISO code as *display metadata* — which is why filtering, sorting, uniqueness, and CSV export all reuse the numeric paths untouched, and why re-denominating a column rewrites zero rows. - -## Step 2: Add the icon - -Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: - -```tsx -import type { SVGProps } from 'react' - -/** - * Type {name} icon component - {what the glyph is} for {name} columns - * @param props - SVG properties including className, fill, etc. - */ -export function Type{Pascal}(props: SVGProps) { - return ( - - ) -} -``` - -- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family. -- Center the glyph on the viewBox's optical center (**y = 10.5**, **x = 10.25**) — every sibling does, and a few tenths off is visible at `size-[14px]`. -- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`. - -## Step 3: Write the type file - -`apps/sim/lib/table/column-types/{name}.ts`. Copy the closest existing type and change what differs. Every field is required by the interface, so the compiler enumerates them for you — read the TSDoc in `types.ts` rather than guessing. - -The three that are easy to get wrong: - -- **`coerce`** is the *single* write-path implementation. The server runs it before persisting **and** the grid runs it to fill the optimistic cache. Accept every shape the value legitimately arrives in (paste, CSV, tool write), because rejecting means the cell is nulled. -- **`isCompatibleWith`** gates type conversion and must read the value **exactly as `coerce` will**, or a conversion will pass its check and then null the cell. -- **`ownedMetadata`** lists the `ColumnDefinition` keys your type owns. Anything you add must also be added to `TYPE_SPECIFIC_COLUMN_KEYS` in `types.ts` and given a phrase in `FOREIGN_METADATA_VERB` in `validation.ts` — both are `Record`-typed, so the compiler will tell you. - -## Step 4: Register - -Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`. - -`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record` against it, which is the gate). `constants.ts` re-exports it, so `columnTypeSchema = z.enum(COLUMN_TYPES)` picks your type up with no edit. **Type-specific metadata does not** — see the next step. - -## Step 5: Migrations (only if the stored bytes change) - -If converting an existing column **to** your type must rewrite cells, add `migrateCellsTo` in `registry.server.ts`; if converting **away** must rewrite them, add `migrateCellsFrom`. - -This is load-bearing, not cosmetic: filters and sorts apply `jsonbCast` to whatever is stored, so leaving a non-castable string behind makes **every query on that column fail** — not merely render oddly. - -Prefer set-based SQL. When the transform genuinely needs JS (`currency`'s separator disambiguation), compute the values during the compatibility scan and pass them through `resolved`, then apply them in one batched statement. - -## Naming Convention - -- Type id: lowercase, singular — `currency`, not `Currency` or `currencies` -- File: `column-types/{id}.ts`, export `const {id}ColumnType` -- Icon: `type-{kebab}.tsx`, export `Type{Pascal}` - -## Watch out - -- **Import cycles.** `column-types/select.ts` imports `select-values.ts`, so `select-values.ts` must **not** import the registry — that closes a cycle and fails at module init. Inside a type's own helper module the string literal is the implementation, not a config leak. -- **The client-safe boundary.** `registry.ts` and everything it imports must stay free of `@sim/db`, `drizzle-orm`, and `next/server` — the tables grid imports it directly. A React icon is fine (it's a component *reference*, never called server-side). Only `registry.server.ts` may touch drizzle. -- **Don't re-export the registry from `@/lib/table`.** 44 server modules import that barrel; routing this through it pulls `@sim/emcn/icons` into all of them. Deep-import `@/lib/table/column-types`. -- **`import.ts`'s `coerceValue` is a SECOND write path and is not opt-in.** Importing into a column of your type always hits it, and its `default` arm silently `String(value)`s — so a missing `case` stores text in a column whose `jsonbCast` is numeric, and then every filter and sort on that column errors in Postgres. Add a `case`, even though the switch compiles without one. (It is deliberately separate from the registry's `coerce`: an import wants an unparseable value to survive as its raw string so the row error can name it.) -- **CSV inference** is an ordered heuristic in `import.ts`, deliberately not registry-driven. A new type is not inferred from a CSV unless you extend `inferColumnType` — usually you should not, since inference cannot supply configuration (an option set, a currency code). - -## If your type owns metadata, read this - -Registering the *type* is compiler-enforced. Registering its *metadata* is not, and that is where the remaining manual work lives. A key like `precision` has to be added in each of these, none of which will fail to compile if you forget: - -| Where | What happens if you forget | -|---|---| -| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) | -| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type | -| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved | -| `columns/service.ts` `addTableColumn` param type | callers cannot pass it | -| A metadata-only update path (`updateColumnCurrency` is the model) + a branch in both column routes + the copilot tool | changing it on an existing column is a silent 200 no-op | -| `column-config-sidebar.tsx` | no UI to set it | -| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default | - -`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit. - -**Known gap:** the metadata-only update path is ~6 near-identical copies (service + 2 routes + copilot). A `metadataUpdate` descriptor on `ColumnTypeServerDefinition` would collapse them; until that exists, copy `currency`'s. - -## Checklist Before Finishing - -- [ ] Added to the `ColumnType` union in `column-types/types.ts` -- [ ] `column-types/{id}.ts` created, every interface field filled in -- [ ] Registered in **both** `registry.ts` and `registry.server.ts` -- [ ] Icon added, centered on the family's optical center, exported alphabetically -- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change -- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB` -- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code -- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx` - -## Final Validation (Required) - -1. **`cd apps/sim && bun run type-check`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. -2. **Grep for leaks** — `grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch. -3. **Run the suite** — `bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. -4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. -5. **Exercise it in the running app** on a table with one column of every type: create, edit inline / in the expanded popover / in the row modal, paste from a spreadsheet, filter, sort, convert to and from other types, export CSV, undo a column delete. diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md deleted file mode 100644 index c73921b22fc..00000000000 --- a/.claude/commands/add-connector.md +++ /dev/null @@ -1,625 +0,0 @@ ---- -description: Add or update a Sim knowledge base connector for syncing documents from an external source, including auth mode, config fields, pagination, document mapping, tags, and registry wiring. Use when working in `apps/sim/connectors/{service}/` or adding a new external document source. -argument-hint: [api-docs-url] ---- - -# Add Connector Skill - -You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base. - -## Your Task - -When the user asks you to create a connector: -1. Use Context7 or WebFetch to read the service's API documentation -2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it -4. Register it in BOTH the server registry and the client-safe meta registry - -## Hard Rule: No Guessed Response Or Document Schemas - -If the service docs do not clearly show the document list response, document fetch response, pagination shape, or metadata fields, you MUST tell the user instead of guessing. - -- Do NOT invent document fields -- Do NOT guess pagination cursors or next-page fields -- Do NOT infer metadata/tag mappings from unrelated endpoints -- Do NOT fabricate `ExternalDocument` content structure from partial docs - -If the source schema is unknown, do one of these instead: -1. Ask the user for sample API responses -2. Ask the user for test credentials so you can verify live payloads -3. Implement only the documented parts of the connector -4. Leave the connector incomplete and explicitly say which fields remain unknown - -## Directory Structure - -Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. - -Create files in `apps/sim/connectors/{service}/`: -``` -connectors/{service}/ -├── index.ts # Barrel export (re-exports the runtime connector) -├── meta.ts # ConnectorMeta — client-safe declarative metadata -└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions -``` - -- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. -- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). - -## Authentication - -Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): - -```typescript -type ConnectorAuthConfig = - | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] } - | { mode: 'apiKey'; label?: string; placeholder?: string } -``` - -### OAuth mode -For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically. - -### API key mode -For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. - -## Connector Structure (meta.ts + runtime) - -The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. - -### `meta.ts` — client-safe metadata - -```typescript -import { {Service}Icon } from '@/components/icons' -import type { ConnectorMeta } from '@/connectors/types' - -export const {service}ConnectorMeta: ConnectorMeta = { - id: '{service}', - name: '{Service}', - description: 'Sync documents from {Service} into your knowledge base', - version: '1.0.0', - icon: {Service}Icon, - - auth: { - mode: 'oauth', - provider: '{service}', // Must match OAuthService in lib/oauth/types.ts - requiredScopes: ['read:...'], - }, - - configFields: [ - // Rendered dynamically by the add-connector modal UI - // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below - ], - - // Optional: tag definitions are metadata too — declare them here - // tagDefinitions: [ ... ], -} -``` - -Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. - -### `{service}.ts` — runtime (OAuth example) - -```typescript -import { createLogger } from '@sim/logger' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import { {service}ConnectorMeta } from '@/connectors/{service}/meta' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') - -export const {service}Connector: ConnectorConfig = { - ...{service}ConnectorMeta, - - listDocuments: async (accessToken, sourceConfig, cursor) => { - // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) - // Or full documents with content (if list API returns content inline) - // Return { documents: ExternalDocument[], nextCursor?, hasMore } - }, - - getDocument: async (accessToken, sourceConfig, externalId) => { - // Fetch full content for a single document - // Return ExternalDocument with contentDeferred: false, or null - }, - - validateConfig: async (accessToken, sourceConfig) => { - // Return { valid: true } or { valid: false, error: 'message' } - }, - - // Optional: map source metadata to semantic tag keys (translated to slots by sync engine) - mapTags: (metadata) => { - // Return Record with keys matching tagDefinitions[].id - }, -} -``` - -Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTags` when the source payload shape is documented or live-verified. If not, tell the user and stop rather than guessing. - -### API key connector example - -The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. - -```typescript -// meta.ts -export const {service}ConnectorMeta: ConnectorMeta = { - id: '{service}', - name: '{Service}', - description: 'Sync documents from {Service} into your knowledge base', - version: '1.0.0', - icon: {Service}Icon, - - auth: { - mode: 'apiKey', - label: 'API Key', // Shown above the input field - placeholder: 'Enter your {Service} API key', // Input placeholder - }, - - configFields: [ /* ... */ ], -} - -// {service}.ts -export const {service}Connector: ConnectorConfig = { - ...{service}ConnectorMeta, - listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, - getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, - validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, -} -``` - -## ConfigField Types - -The add-connector modal renders these automatically — no custom UI needed. - -Three field types are supported: `short-input`, `dropdown`, and `selector`. - -```typescript -// Text input -{ - id: 'domain', - title: 'Domain', - type: 'short-input', - placeholder: 'yoursite.example.com', - required: true, -} - -// Dropdown (static options) -{ - id: 'contentType', - title: 'Content Type', - type: 'dropdown', - required: false, - options: [ - { label: 'Pages only', id: 'page' }, - { label: 'Blog posts only', id: 'blogpost' }, - { label: 'All content', id: 'all' }, - ], -} -``` - -## Dynamic Selectors (Canonical Pairs) - -Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`. - -The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`. - -### Rules - -1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`. -2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required. -3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`. -4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children. - -### Selector canonical pair example (Airtable base → table cascade) - -```typescript -configFields: [ - // Base: selector (basic) + manual (advanced) - { - id: 'baseSelector', - title: 'Base', - type: 'selector', - selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts - canonicalParamId: 'baseId', - mode: 'basic', - placeholder: 'Select a base', - required: true, - }, - { - id: 'baseId', - title: 'Base ID', - type: 'short-input', - canonicalParamId: 'baseId', - mode: 'advanced', - placeholder: 'e.g. appXXXXXXXXXXXXXX', - required: true, - }, - // Table: selector depends on base (basic) + manual (advanced) - { - id: 'tableSelector', - title: 'Table', - type: 'selector', - selectorKey: 'airtable.tables', - canonicalParamId: 'tableIdOrName', - mode: 'basic', - dependsOn: ['baseSelector'], // References the selector field ID - placeholder: 'Select a table', - required: true, - }, - { - id: 'tableIdOrName', - title: 'Table Name or ID', - type: 'short-input', - canonicalParamId: 'tableIdOrName', - mode: 'advanced', - placeholder: 'e.g. Tasks', - required: true, - }, - // Non-selector fields stay as-is - { id: 'maxRecords', title: 'Max Records', type: 'short-input', ... }, -] -``` - -### Selector with domain dependency (Jira/Confluence pattern) - -When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`. - -```typescript -configFields: [ - { - id: 'domain', - title: 'Jira Domain', - type: 'short-input', - placeholder: 'yoursite.atlassian.net', - required: true, - }, - { - id: 'projectSelector', - title: 'Project', - type: 'selector', - selectorKey: 'jira.projects', - canonicalParamId: 'projectKey', - mode: 'basic', - dependsOn: ['domain'], - placeholder: 'Select a project', - required: true, - }, - { - id: 'projectKey', - title: 'Project Key', - type: 'short-input', - canonicalParamId: 'projectKey', - mode: 'advanced', - placeholder: 'e.g. ENG, PROJ', - required: true, - }, -] -``` - -### How `dependsOn` maps to `SelectorContext` - -The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`): - -``` -oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId, -siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId -``` - -### Available selector keys - -Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors: - -| SelectorKey | Context Deps | Returns | -|-------------|-------------|---------| -| `airtable.bases` | credential | Base ID + name | -| `airtable.tables` | credential, `baseId` | Table ID + name | -| `slack.channels` | credential | Channel ID + name | -| `gmail.labels` | credential | Label ID + name | -| `google.calendar` | credential | Calendar ID + name | -| `linear.teams` | credential | Team ID + name | -| `linear.projects` | credential, `teamId` | Project ID + name | -| `jira.projects` | credential, `domain` | Project key + name | -| `confluence.spaces` | credential, `domain` | Space key + name | -| `notion.databases` | credential | Database ID + name | -| `asana.workspaces` | credential | Workspace GID + name | -| `microsoft.teams` | credential | Team ID + name | -| `microsoft.channels` | credential, `teamId` | Channel ID + name | -| `webflow.sites` | credential | Site ID + name | -| `outlook.folders` | credential | Folder ID + name | - -## ExternalDocument Shape - -Every document returned from `listDocuments`/`getDocument` must include: - -```typescript -{ - externalId: string // Source-specific unique ID - title: string // Document title - content: string // Extracted plain text (or '' if contentDeferred) - contentDeferred?: boolean // true = content will be fetched via getDocument - mimeType: 'text/plain' // Always text/plain (content is extracted) - contentHash: string // Metadata-based hash for change detection - sourceUrl?: string // Link back to original (stored on document record) - metadata?: Record // Source-specific data (fed to mapTags) -} -``` - -## Content Deferral (Required for file/content-download connectors) - -**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents. - -This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved. - -### When to use `contentDeferred: true` - -- The service's list API does NOT return document content (only metadata) -- Content requires a separate download/export API call per document -- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub - -### When NOT to use `contentDeferred` - -- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes) -- No per-document API call is needed to get content - -### Content Hash Strategy - -Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content. - -Good metadata hash sources: -- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited -- Git blob SHA — unique per content version -- API-provided content hash (e.g., Dropbox `content_hash`) -- Version number (e.g., Confluence page version) - -Format: `{service}:{id}:{changeIndicator}` - -```typescript -// Google Drive: modifiedTime changes on edit -contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}` - -// GitHub: blob SHA is a content-addressable hash -contentHash: `gitsha:${item.sha}` - -// Dropbox: API provides content_hash -contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}` - -// Confluence: version number increments on edit -contentHash: `confluence:${page.id}:${page.version.number}` -``` - -**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this. - -### Implementation Pattern - -```typescript -// 1. Create a stub function (sync, no API calls) -function fileToStub(file: ServiceFile): ExternalDocument { - return { - externalId: file.id, - title: file.name || 'Untitled', - content: '', - contentDeferred: true, - mimeType: 'text/plain', - sourceUrl: `https://service.com/file/${file.id}`, - contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`, - metadata: { /* fields needed by mapTags */ }, - } -} - -// 2. listDocuments returns stubs (fast, metadata only) -listDocuments: async (accessToken, sourceConfig, cursor) => { - const response = await fetchWithRetry(listUrl, { ... }) - const files = (await response.json()).files - const documents = files.map(fileToStub) - return { documents, nextCursor, hasMore } -} - -// 3. getDocument fetches content and returns full doc with SAME contentHash -getDocument: async (accessToken, sourceConfig, externalId) => { - const metadata = await fetchWithRetry(metadataUrl, { ... }) - const file = await metadata.json() - if (file.trashed) return null - - try { - const content = await fetchContent(accessToken, file) - if (!content.trim()) return null - const stub = fileToStub(file) - return { ...stub, content, contentDeferred: false } - } catch (error) { - logger.warn(`Failed to fetch content for: ${file.name}`, { error }) - return null - } -} -``` - -### Reference Implementations - -- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash -- **GitHub**: `connectors/github/github.ts` — git blob SHA hash -- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash -- **Confluence**: `connectors/confluence/confluence.ts` — version number hash - -## tagDefinitions — Declared Tag Definitions - -Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes. -On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names. - -```typescript -tagDefinitions: [ - { id: 'labels', displayName: 'Labels', fieldType: 'text' }, - { id: 'version', displayName: 'Version', fieldType: 'number' }, - { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, -], -``` - -Each entry has: -- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`) -- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified") -- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from - -Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`. -The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`. - -## `@/connectors/utils` Helpers - -Reuse these instead of inlining the same logic (the validator enforces them): - -- `htmlToPlainText(html)` — strip HTML to plain text before indexing `ExternalDocument.content`. Never index raw HTML. -- `computeContentHash(content)` — stable content hash for change detection. -- `parseTagDate(value)` — parse to a valid `Date` or `undefined` (guards Invalid Date). Use in `mapTags` for date fields. -- `joinTagArray(value)` — validate an array and join to a comma-separated string, or `undefined`. Use in `mapTags` for array/label fields. -- `parseMultiValue(value)` — normalize a value into a `string[]`. - -## mapTags — Metadata to Semantic Keys - -Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set. -The sync engine calls this automatically and translates semantic keys to actual DB slots -using the `tagSlotMapping` stored on the connector. - -Return keys must match the `id` values declared in `tagDefinitions`. - -Use the `@/connectors/utils` helpers for the common transforms — don't hand-roll date/array validation: - -```typescript -import { joinTagArray, parseTagDate } from '@/connectors/utils' - -mapTags: (metadata: Record): Record => { - const result: Record = {} - - // joinTagArray validates the array and joins to a comma-separated string (undefined if empty) - const labels = joinTagArray(metadata.labels) - if (labels) result.labels = labels - - // Validate numbers — guard against NaN - if (metadata.version != null) { - const num = Number(metadata.version) - if (!Number.isNaN(num)) result.version = num - } - - // parseTagDate returns a valid Date or undefined (guards against Invalid Date) - const lastModified = parseTagDate(metadata.lastModified) - if (lastModified) result.lastModified = lastModified - - return result -} -``` - -## External API Calls — Use `fetchWithRetry` - -All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged. - -For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max). - -```typescript -import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils' - -// Background sync — use defaults -const response = await fetchWithRetry(url, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, -}) - -// validateConfig — tighter retry budget -const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS) -``` - -## sourceUrl - -If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path). - -## Capped or Incomplete Listings — `syncContext.listingCapped` (REQUIRED) - -If `listDocuments` can ever return **less than the full source set** on a non-incremental sync — a `maxItems`/`maxDocuments`-style cap, or a transient per-item error that drops a still-existing document from the listing — it MUST set `syncContext.listingCapped = true` when that happens. - -The sync engine reconciles deletions by comparing the full listing against stored documents: anything not seen is **hard-deleted** (sync-engine.ts, gated on `!syncContext?.listingCapped`). A truncated listing without this flag deletes every real document beyond the cap. This was the single most common bug found when auditing connectors — do not omit it. - -```typescript -if (hitLimit && syncContext) { - syncContext.listingCapped = true -} -``` - -Rules: -- Set it when a user-configured cap truncates the listing while more documents exist -- Set it when a thrown error caused a still-present document to be skipped during listing -- Do NOT set it when the source is genuinely exhausted (deleted documents must still reconcile) -- Do NOT set it for intentional scope filters (e.g. a date cutoff) — out-of-scope documents should be reconciled normally - -## Sync Engine Behavior (Do Not Modify) - -The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It: -1. Calls `listDocuments` with pagination until `hasMore` is false -2. Compares `contentHash` to detect new/changed/unchanged documents -3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically -4. Handles soft-delete of removed documents -5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column - -You never need to modify the sync engine when adding a connector. - -## Icon - -The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain. - -If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG. - -## Registering - -Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. - -1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): - -```typescript -import { {service}Connector } from '@/connectors/{service}' - -export const CONNECTOR_REGISTRY: ConnectorRegistry = { - // ... existing connectors ... - {service}: {service}Connector, -} -``` - -2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): - -```typescript -import { {service}ConnectorMeta } from '@/connectors/{service}/meta' - -export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { - // ... existing connector metas ... - {service}: {service}ConnectorMeta, -} -``` - -`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. - -## Reference Implementations - -- **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination -- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument` -- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing -- **OAuth + inline content**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching -- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth - -## Checklist - -- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports -- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions -- [ ] Created `connectors/{service}/index.ts` barrel export -- [ ] **Auth configured correctly:** - - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` - - API key: `auth.label` and `auth.placeholder` set appropriately -- [ ] **Selector fields configured correctly (if applicable):** - - Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`) - - `required` is identical on both fields in each canonical pair - - `selectorKey` exists in `hooks/selectors/registry.ts` - - `dependsOn` references selector field IDs (not `canonicalParamId`) - - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` -- [ ] `listDocuments` handles pagination with metadata-based content hashes -- [ ] `syncContext.listingCapped = true` set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents -- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) -- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument` -- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative) -- [ ] `metadata` includes source-specific data for tag mapping -- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags` -- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions) -- [ ] `validateConfig` verifies the source is accessible -- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) -- [ ] All optional config fields validated in `validateConfig` -- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered the full connector in `connectors/registry.server.ts` -- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.claude/commands/add-enrichment.md b/.claude/commands/add-enrichment.md deleted file mode 100644 index b8da9bb3bb4..00000000000 --- a/.claude/commands/add-enrichment.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -description: Add a code-defined table enrichment (registry entry) under `apps/sim/enrichments/` backed by an ordered provider cascade, ensuring every provider tool it calls has hosted-key support. Use when adding a per-row table enrichment that fills cells via existing Sim tools. -argument-hint: ---- - -# Adding a Table Enrichment - -Enrichments are code-defined entries in `apps/sim/enrichments/` that run **directly per table row** (no workflow). Each enrichment declares inputs, outputs, and an ordered list of **providers**; the cascade runner tries providers in order and the first non-empty result fills the cell. Each provider calls one existing Sim tool via `executeTool`, which injects the workspace's BYOK key or a **hosted key** and bills usage automatically. - -Because enrichments run on Sim's hosted keys by default, **every provider tool you reference must have hosted-key support** — otherwise it can only run when the workspace brings its own key. This command makes that check a required step. - -## Overview - -| Step | What | Where | -|------|------|-------| -| 1 | Pick the data-source tool(s) for each output | `tools/{service}/` + `tools/registry.ts` | -| 2 | **Verify each tool has `hosting`; if not, run `/add-hosted-key`** | `tools/{service}/{action}.ts` | -| 3 | Write the enrichment definition | `enrichments/{name}/{name}.ts` + `index.ts` | -| 4 | Register it | `enrichments/registry.ts` | -| 5 | Verify | tsc / biome / manual run | - -## Architecture (what you're plugging into) - -- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, mapOutput }`. Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe. -- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`. -- **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry. -- **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here. - -Outputs automatically become table columns; billing, the catalog/sidebar UI, the column meta-header icon, and per-row execution all work with no extra wiring. - -## Step 1: Pick the data-source tool(s) - -For each output the enrichment produces, decide which existing tool provides it. Look up the service's API and the tool in `apps/sim/tools/{service}/` (e.g. `hunter_email_finder`, `pdl_person_enrich`, `pdl_company_enrich`). Confirm: - -- The tool id is registered in `apps/sim/tools/registry.ts`. -- Its `params` accept what you can derive from table columns (read the tool's `params`). -- Its `outputs` / `transformResponse` actually expose the field you need (read the real output shape — don't assume). - -Order providers **cheapest / most-likely-to-hit first**; the cascade stops at the first non-empty result. Apollo / LinkedIn are not hosted-safe (ToS) — don't use them. - -## Step 2: Verify hosted-key support — chain to `/add-hosted-key` if missing - -**This is the required gate.** For every tool a provider calls, open `apps/sim/tools/{service}/{action}.ts` and check for a `hosting` block: - -```typescript -hosting: { - envKeyPrefix: 'SERVICE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'service', - pricing: { /* ... */ }, - rateLimit: { /* ... */ }, -} -``` - -- **If `hosting` is present** — good. Note the `envKeyPrefix`; the deployment needs `{PREFIX}_COUNT` + `{PREFIX}_1..N` env vars set for the hosted key to actually resolve at runtime (ops concern, not code). If those env vars aren't set in the target environment, the provider will only run with a workspace BYOK key. -- **If `hosting` is absent** — the tool can't use a Sim-provided key, so the enrichment would silently produce blank cells on hosted Sim. **Stop and run `/add-hosted-key `** to add hosted-key support to that tool first, then come back. Do this for every provider tool that lacks it. - -Why it matters: the cascade runner only bills (and only reads `output.cost.total`) when `executeTool` injected a hosted key, which requires the tool's `hosting` config. No `hosting` → no hosted key → the enrichment depends entirely on per-workspace BYOK. - -## Step 3: Write the enrichment definition - -Create `apps/sim/enrichments/{name}/{name}.ts` and a barrel `index.ts`. Mirror the existing entries (`work-email`, `phone-number`, `company-domain`, `company-info`). - -```typescript -import { SomeIcon } from '@sim/emcn/icons' -import { filterUndefined } from '@sim/utils/object' -import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' -import type { EnrichmentConfig } from '@/enrichments/types' - -export const myEnrichment: EnrichmentConfig = { - id: 'my-enrichment', - name: 'My Enrichment', - description: 'One concise sentence describing what it finds.', - icon: SomeIcon, - inputs: [ - // Person enrichments take a single canonical `fullName` (Clay-style); - // split it with splitName() for tools that need first/last. - { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string' }, - ], - outputs: [{ id: 'value', name: 'value', type: 'string' }], - providers: [ - toolProvider({ - id: 'provider-a', - label: 'Provider A', - toolId: 'service_action', // must have `hosting` (Step 2) - buildParams: (inputs) => { - // Return null when there aren't enough inputs → cascade skips this provider. - const name = splitName(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!name || !domain) return null - return { domain, first_name: name.firstName, last_name: name.lastName } - }, - mapOutput: (output) => { - // Return { [outputId]: value } on a hit, or null to fall through. - const value = str(output.value) - return value ? { value } : null - }, - }), - // ...additional fallback providers, in priority order. - ], -} -``` - -```typescript -// apps/sim/enrichments/{name}/index.ts -export { myEnrichment } from './my-enrichment' -``` - -Rules: -- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call. -- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs). -- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config). - -## Step 4: Register it - -In `apps/sim/enrichments/registry.ts`, import and add the entry (catalog order is registration order): - -```typescript -import { myEnrichment } from '@/enrichments/my-enrichment' - -export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { - // ...existing - [myEnrichment.id]: myEnrichment, -} -``` - -## Step 5: Verify - -1. `bun run type-check` (from `apps/sim`) and `bunx biome check` on the changed files. -2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. -3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. - -## Checklist - -- [ ] Each output mapped to a real tool field (verified against the tool's `params`/`outputs`) -- [ ] **Every provider tool has a `hosting` block — ran `/add-hosted-key` for any that didn't** -- [ ] Providers ordered cheapest / most-likely-first; Apollo/LinkedIn not used -- [ ] Enrichment file is client-safe (no `@/tools` import); uses `toolProvider` + shared helpers -- [ ] `buildParams` returns `null` on insufficient inputs; `mapOutput` returns `null` on a miss -- [ ] Registered in `enrichments/registry.ts` -- [ ] tsc + biome clean; created and ran the column end-to-end diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md deleted file mode 100644 index 07e38a50443..00000000000 --- a/.claude/commands/add-feature-flag.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -description: Add a runtime feature flag (AppConfig-backed on prod, secret fallback off-prod), global by default or optionally gated by org id, user id, or platform admin -argument-hint: ---- - -# Add Feature Flag Skill - -You add a **runtime feature flag** to Sim that can change on prod with no redeploy (AWS AppConfig). Prefer a global on/off flag unless the rollout actually needs per-organization, per-user, or platform-admin targeting. When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only). - -## When to use this vs `env-flags.ts` - -- **Feature flag** (`@/lib/core/config/feature-flags.ts`): runtime global on/off by default, optionally scoped by `userId`/`orgId`/admin. This skill. -- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.** - -If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. - -## The flag model - -A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any configured clause matches: - -```ts -interface FeatureFlagRule { - enabled?: boolean // global default for everyone - orgIds?: string[] // allowlisted organization ids - userIds?: string[] // allowlisted user ids - adminEnabled?: boolean // platform admins (user.role === 'admin') -} -``` - -Critically, **none of this is expressible in code** — gating (especially `adminEnabled`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret. - -## Steps - -1. **Confirm the granularity before editing code.** If the user has not already specified it, stop and ask: - - > Should `` be a global on/off flag (recommended), or does it need rollout targeting by organization, user, and/or platform admin? - - - Recommend **global**. Do not infer scoped gating merely because the call site already has a user or organization id. - - If the user chooses scoped gating but does not name the dimensions, ask which of organization, user, and platform admin it needs. Wire only the selected dimensions. - - If the user wants a fixed per-deployment toggle rather than a runtime AppConfig flag, use `env-flags.ts` instead. - -2. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally): - - ```ts - const FEATURE_FLAGS = { - '': { - description: '', - fallback: '', - }, - } - ``` - - `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. - -3. **Gate the call site at the chosen granularity.** For the recommended global mode, pass no context: - - ```ts - import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - - if (await isFeatureEnabled('')) { - // gated behavior - } - ``` - - Do not fetch, resolve, or thread through user or organization context solely for a global flag. - - For scoped rollout, pass only the dimensions the user selected. Admin status is resolved internally, so ordinary callers pass `userId`, not a role: - - ```ts - import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - - if (await isFeatureEnabled('', { userId, orgId })) { - // gated behavior - } - ``` - - - Organization targeting uses `orgId`; user and platform-admin targeting require `userId`. - - Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read. - - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. - - **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig. - -4. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag to the hosted `feature-flags` document using `enabled` for global rollout or only the selected `orgIds`/`userIds`/`adminEnabled` clauses for scoped rollout, then start a `sim--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. - -5. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts` that matches the chosen granularity. For a global flag, exercise `isFeatureEnabled('')` with an AppConfig `enabled` rule and toggle the fallback secret for the off-AppConfig path. For scoped rollout, cover only the selected clauses and mock `isPlatformAdmin` when testing `adminEnabled`. - -6. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. - -## Notes - -- Flag keys are `kebab-case`. -- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. -- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. -- Never add or propagate request context unless the user chose scoped rollout. -- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `adminEnabled` is the deciding clause. diff --git a/.claude/commands/add-hosted-key.md b/.claude/commands/add-hosted-key.md deleted file mode 100644 index cf1b4a4b4b1..00000000000 --- a/.claude/commands/add-hosted-key.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -description: Add hosted API key support to a tool so Sim provides the key (metered and billed to the workspace) when a user has not brought their own. Use when adding a `hosting` config to a tool under `apps/sim/tools/{service}/`. -argument-hint: ---- - -# Adding Hosted Key Support to a Tool - -When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace. - -## Overview - -| Step | What | Where | -|------|------|-------| -| 1 | Register BYOK provider ID | `tools/types.ts`, `lib/api/contracts/byok-keys.ts` | -| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) | -| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` | -| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` | -| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) | -| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) | - -## Step 1: Register the BYOK Provider ID - -Add the new provider to the `BYOKProviderId` union in `tools/types.ts`: - -```typescript -export type BYOKProviderId = - | 'openai' - | 'anthropic' - // ...existing providers - | 'your_service' -``` - -Then add the same provider id to the `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` (this is what the byok-keys route validates against): - -```typescript -export const byokProviderIdSchema = z.enum([ - 'openai', - 'anthropic', - // ...existing providers - 'your_service', -]) -``` - -## Step 2: Research the API's Pricing Model and Rate Limits - -**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand: - -### Pricing - -1. **How the API charges** — per request, per credit, per token, per step, per minute, etc. -2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers -3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode) -4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan - -### Rate Limits - -1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc. -2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings -3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput -4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc. -5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently - -Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth. - -### Setting Our Rate Limits - -Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes: - -- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much. -- **`custom`** — `requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too. - -When choosing values for `requestsPerMinute` and any dimension limits: - -- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling. -- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count. -- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput. - -## Step 3: Add `hosting` Config to the Tool - -Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit. - -```typescript -hosting: { - envKeyPrefix: 'YOUR_SERVICE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'your_service', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Response missing creditsUsed field') - } - const creditsUsed = output.creditsUsed as number - const cost = creditsUsed * 0.001 // dollars per credit - return { cost, metadata: { creditsUsed } } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, -}, -``` - -### Hosted Key Env Var Convention - -Keys use a numbered naming pattern driven by a count env var: - -``` -YOUR_SERVICE_API_KEY_COUNT=3 -YOUR_SERVICE_API_KEY_1=sk-... -YOUR_SERVICE_API_KEY_2=sk-... -YOUR_SERVICE_API_KEY_3=sk-... -``` - -The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var. - -### Pricing: Prefer API-Reported Cost - -Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts. - -**When the API reports cost** — use it directly and throw if missing: - -```typescript -pricing: { - type: 'custom', - getCost: (params, output) => { - if (output.creditsUsed == null) { - throw new Error('Response missing creditsUsed field') - } - // $0.001 per credit — from https://example.com/pricing - const cost = (output.creditsUsed as number) * 0.001 - return { cost, metadata: { creditsUsed: output.creditsUsed } } - }, -}, -``` - -**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on: - -```typescript -pricing: { - type: 'custom', - getCost: (params, output) => { - if (!Array.isArray(output.searchResults)) { - throw new Error('Response missing searchResults, cannot determine cost') - } - // Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing - const credits = Number(params.num) > 10 ? 2 : 1 - return { cost: credits * 0.001, metadata: { credits } } - }, -}, -``` - -**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies. - -### Capturing Cost Data from the API - -If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output: - -```typescript -transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - results: data.results, - creditsUsed: data.creditsUsed, // pass through for getCost - }, - } -}, -``` - -For async/polling tools, capture it in `postProcess` when the job completes: - -```typescript -if (jobData.status === 'completed') { - result.output = { - data: jobData.data, - creditsUsed: jobData.creditsUsed, - } -} -``` - -## Step 4: Hide the API Key Field When Hosted - -In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key: - -```typescript -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - hideWhenHosted: true, -}, -``` - -The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`). - -### Excluding Specific Operations from Hosted Key Support - -When a block has multiple operations but some operations should **not** use a hosted key (e.g., the underlying API is deprecated, unsupported, or too expensive), use the **duplicate apiKey subblock** pattern. This is the same pattern Exa uses for its `research` operation: - -1. **Remove the `hosting` config** from the tool definition for that operation — it must not have a `hosting` object at all. -2. **Duplicate the `apiKey` subblock** in the block config with opposing conditions: - -```typescript -// API Key — hidden when hosted for operations with hosted key support -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - hideWhenHosted: true, - condition: { field: 'operation', value: 'unsupported_op', not: true }, -}, -// API Key — always visible for unsupported_op (no hosted key support) -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - condition: { field: 'operation', value: 'unsupported_op' }, -}, -``` - -Both subblocks share the same `id: 'apiKey'`, so the same value flows to the tool. The conditions ensure only one is visible at a time. The first has `hideWhenHosted: true` and shows for all hosted operations; the second has no `hideWhenHosted` and shows only for the excluded operation — meaning users must always provide their own key for that operation. - -To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`. - -**Reference implementations:** -- **Exa** (`blocks/blocks/exa.ts`): `exa_research` operation excluded from hosting — duplicate `apiKey` pair around lines ~348-365 -- **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API) - -## Step 5: Add to the BYOK Settings UI - -Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`: - -```typescript -{ - id: 'your_service', - name: 'Your Service', - icon: YourServiceIcon, - description: 'What this service does', - placeholder: 'Enter your API key', -}, -``` - -## Step 6: Summarize Pricing and Throttling Comparison - -After all code changes are complete, output a detailed summary to the user covering: - -### What to include - -1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses. -2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost). -3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account. -4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits. -5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API. -6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs. - -### Format - -Present this as a structured summary with clear headings. Example: - -``` -### Pricing -- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model -- **Response reports cost?**: No — only token counts in `usage` field -- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing -- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models - -### Throttling -- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier) -- **Per-key or per-account**: Per key — more keys = more throughput -- **Our config**: 60 RPM per workspace (per_request mode) -- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N -- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit -``` - -This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring. - -## Checklist - -- [ ] Provider added to `BYOKProviderId` in `tools/types.ts` -- [ ] Provider added to `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` -- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses -- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers -- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit` -- [ ] `getCost` throws if required cost data is missing from the response -- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it -- [ ] `hideWhenHosted: true` added to the API key subblock in the block config -- [ ] Provider entry added to the BYOK settings UI with icon and description -- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N` -- [ ] Pricing and throttling summary provided to reviewer diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md deleted file mode 100644 index 08d8fc92f08..00000000000 --- a/.claude/commands/add-integration.md +++ /dev/null @@ -1,1000 +0,0 @@ ---- -description: Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, resolved-secret/model-input safety, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`. -argument-hint: [api-docs-url] ---- - -# Add Integration Skill - -You are an expert at adding complete integrations to Sim. This skill orchestrates the full process of adding a new service integration. - -## Overview - -Adding an integration involves these steps in order: -1. **Research** - Read the service's API documentation -2. **Create Tools** - Build tool configurations for each API operation -3. **Create Block** - Build the block UI configuration -4. **Add Icon** - Add the service's brand icon -5. **Create Triggers** (optional) - If the service supports webhooks -6. **Register** - Register tools, block, and triggers in their registries -7. **Configure Deployment Availability** - Wire OAuth client and service-account metadata -8. **Generate and Validate the Catalog** - Regenerate docs/catalog artifacts and run drift checks - -## Step 1: Research the API - -Before writing any code: -1. Use Context7 to find official documentation: `mcp__context7__resolve-library-id`, then fetch with `mcp__context7__query-docs` -2. Or use WebFetch to read API docs directly -3. Identify: - - Authentication method (OAuth, API Key, both) - - Available operations (CRUD, search, etc.) - - Required vs optional parameters - - Response structures - -### Hard Rule: No Guessed Response Schemas - -If the official docs do not clearly show the response JSON shape for an endpoint, you MUST stop and tell the user exactly which outputs are unknown. - -- Do NOT guess response field names -- Do NOT infer nested JSON paths from related endpoints -- Do NOT invent output properties just because they seem likely -- Do NOT implement `transformResponse` against unverified payload shapes - -If response schemas are missing or incomplete, do one of the following before proceeding: -1. Ask the user for sample responses -2. Ask the user for test credentials so you can verify the live payload -3. Reduce the scope to only endpoints whose response shapes are documented -4. Leave the tool unimplemented and explicitly report why - -## Step 2: Create Tools - -### Directory Structure -``` -apps/sim/tools/{service}/ -├── index.ts # Barrel exports -├── types.ts # TypeScript interfaces -├── {action1}.ts # Tool for action 1 -├── {action2}.ts # Tool for action 2 -└── ... -``` - -### Key Patterns - -**types.ts:** -```typescript -import type { ToolResponse } from '@/tools/types' - -export interface {Service}{Action}Params { - accessToken: string // For OAuth services - // OR - apiKey: string // For API key services - - requiredParam: string - optionalParam?: string -} - -export interface {Service}Response extends ToolResponse { - output: { - // Define output structure - } -} -``` - -**Tool file pattern:** -```typescript -export const {service}{Action}Tool: ToolConfig = { - id: '{service}_{action}', - name: '{Service} {Action}', - description: '...', - version: '1.0.0', - - oauth: { required: true, provider: '{service}' }, // If OAuth - - params: { - accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' }, - // ... other params - }, - - request: { url, method, headers, body }, - - transformResponse: async (response) => { - const data = await response.json() - return { - success: true, - output: { - field: data.field ?? null, // Always handle nullables - }, - } - }, - - outputs: { /* ... */ }, -} -``` - -### Critical Rules -- `visibility: 'hidden'` for OAuth tokens -- `visibility: 'user-only'` for API keys and user credentials -- `visibility: 'user-or-llm'` for operation parameters -- Always use `?? null` for nullable API response fields -- Always use `?? []` for optional array fields -- Set `optional: true` for outputs that may not exist -- Never output raw JSON dumps - extract meaningful fields -- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic -- If you do not know the response JSON shape from docs or verified examples, you MUST tell the user and stop. Never guess outputs or response mappings. - -### Resolved Secrets at Model and Persistence Boundaries - -Classify every request field before implementing the tool: - -This is opt-in, not a blanket integration migration. Add a model-input declaration only when the -service's official documentation or an unambiguous local execution path proves that the exact -field is consumed by an AI model. If that cannot be established, preserve existing tool behavior -and leave the field unannotated. - -- **Ordinary provider/API input:** leave it unchanged. Explicit `{{...}}` references resolve and are - sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque - payload is not model-visible merely because the provider is AI-backed or may process the - referenced resource later. -- **Text or structured content consumed by an AI model:** declare `request.modelInput` with - `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces - activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or - JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the - rebuilt params reproduces the projected selection. -- **Serialized model content sent directly to an external provider:** include the serialized - top-level param in `request.modelInput`. Project the private copy before the existing request - formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not - valid in the serialized grammar. Do not introduce a second hard-rejection path. -- **Opaque model input owned by an authenticated internal route** such as inline audio, image, - video, or document bytes: add `privateProvenance` to a projected request, or use - `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, - paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize - stored bytes independently at model egress. The route must call - `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must - apply the workspace-file provenance guard before reading a persisted workspace file. -- **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model - (table cells, Agent memory, knowledge documents/chunks, workspace-file contents, or child-workflow - input): transport encrypted field-scoped provenance with `request.secretProvenance`. The - authenticated receiver validates the exact selection and scope, strips the private envelope, and - persists, imports, or propagates it at the owning boundary. Preserve shared legacy behavior for - headerless internal calls and rows/files whose provenance marker is `NULL`; never invent a - tool-local migration rule. - -Hard rules: - -- Never substitute secret plaintext into source or serialize plaintext provenance. -- Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns - transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Project proven - model-visible external fields with `request.modelInput`; otherwise preserve ordinary request - semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. -- Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated - by Sim's resolved-secret provenance for that execution/tool call. -- Do not add provenance merely because a value is persisted, returned by a tool, or appears in a - filename. Require a concrete Sim `{{...}}` resolution path and a later model/log boundary. If an - unsupported field can resolve a secret but does not justify durable tracking (for example a - `file_write` path), reject it at that exact ingress. -- At diagnostic boundaries, project only values carrying execution-scoped provenance. Ordinary - provider responses, filenames, URLs, and errors remain unchanged when Sim did not resolve a - secret into them. - -Add focused tests covering named projection, ordinary identical text without provenance, nested and -serialized shape handling, unchanged ordinary external inputs, malformed/incomplete private metadata -failing closed, headerless legacy requests, and absence of private metadata in the public tool result. -For durable sinks, also cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, -stale/missing sidecars, and scope isolation. - -## Step 3: Create Block - -### File Location -`apps/sim/blocks/blocks/{service}.ts` - -### Block Structure -```typescript -import { {Service}Icon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - description: '...', - longDescription: '...', - docsLink: 'https://docs.sim.ai/integrations/{service}', - category: 'tools', - integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) - tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) - bgColor: '#HEXCOLOR', - icon: {Service}Icon, - authMode: AuthMode.OAuth, // or AuthMode.ApiKey - - subBlocks: [ - // Operation dropdown - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Operation 1', id: 'action1' }, - { label: 'Operation 2', id: 'action2' }, - ], - value: () => 'action1', - }, - // Credential field - { - id: 'credential', - title: '{Service} Account', - type: 'oauth-input', - serviceId: '{service}', - requiredScopes: getScopesForService('{service}'), - required: true, - }, - // Conditional fields per operation - // ... - ], - - tools: { - access: ['{service}_action1', '{service}_action2'], - config: { - tool: (params) => `{service}_${params.operation}`, - }, - }, - - outputs: { /* ... */ }, -} -``` - -### Key SubBlock Patterns - -**Condition-based visibility:** -```typescript -{ - id: 'resourceId', - title: 'Resource ID', - type: 'short-input', - condition: { field: 'operation', value: ['read', 'update', 'delete'] }, - required: { field: 'operation', value: ['read', 'update', 'delete'] }, -} -``` - -**DependsOn for cascading selectors:** -```typescript -{ - id: 'project', - type: 'project-selector', - dependsOn: ['credential'], -}, -{ - id: 'issue', - type: 'file-selector', - dependsOn: ['credential', 'project'], -} -``` - -**Basic/Advanced mode for dual UX:** -```typescript -// Basic: Visual selector -{ - id: 'channelSelector', - type: 'channel-selector', - mode: 'basic', - canonicalParamId: 'channel', - dependsOn: ['credential'], -}, -// Advanced: Manual input -{ - id: 'channelId', - type: 'short-input', - mode: 'advanced', - canonicalParamId: 'channel', -} -``` - -Note neither subblock `id` is `channel` — the canonical id is a third name that both members map -onto, and it is the only one that survives serialization. - -**Critical Canonical Param Rules:** -- `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys - groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two - operations that each need their own pair must use two different canonical ids -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. - A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as - in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate - identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the - mutually exclusive sources `required: false`, and enforce "exactly one" at execution -- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent -- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions -- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) -- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`) -- **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation) - -### BlockMeta (Required) - -Export a `{Service}BlockMeta` in the same file as the block — **minimum 7 templates**. See `.agents/skills/add-block/SKILL.md` → "BlockMeta (Required)" for valid `modules` and `category` values and the full pattern. - -```typescript -export const {Service}BlockMeta = { - tags: ['tag1', 'tag2'], - templates: [ - { - icon: {Service}Icon, - title: '{Service} ', - prompt: 'Build a workflow that...', // concrete trigger → transformation → output - modules: ['agent', 'workflows'], - category: 'operations', - tags: ['automation'], - alsoIntegrations: ['slack'], // when the prompt references another service - }, - // ... at least 6 more - ], -} as const satisfies BlockMeta -``` - -## Step 4: Add Icon - -### File Location -`apps/sim/components/icons.tsx` - -### Pattern -```typescript -export function {Service}Icon(props: SVGProps) { - return ( - - {/* SVG paths from user-provided SVG */} - - ) -} -``` - -### Getting Icons -**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG: - -``` -I've completed the integration. Before I can add the icon, please provide the SVG for {Service}. -You can usually find this in the service's brand/press kit page, or copy it from their website. - -Paste the SVG code here and I'll convert it to a React component. -``` - -Once the user provides the SVG: -1. Extract the SVG paths/content -2. Create a React component that spreads props -3. Ensure viewBox is preserved from the original SVG - -### Theme-safety (bare rendering) — REQUIRED - -The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a -neutral page — e.g. the home **Suggested actions** list — in both light and dark -mode. A monochrome logo whose paths hardcode a single near-white or near-black -fill is invisible bare on the matching background (white-on-white in light mode, -black-on-black in dark mode). - -Rules when adding the SVG: - -- **Monochrome logos** (a single white or black mark): draw the shape with - `fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits - white inside dark tiles, near-black inside light tiles (via - `getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible - everywhere. Do NOT set `iconColor` for these. -- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills. - They read on any background. Only set `iconColor` (a vivid brand hex, never a - near-black/near-white tile color) if the bare icon should adopt a brand tint. -- A large white shape with a tiny vivid accent (e.g. a logo where the body is the - white negative space) still vanishes bare — convert the body to `currentColor`. - -Verify with `bun run check:bare-icons` (also runs in CI). It flags purely -monochrome hazards; for partial-accent logos, eyeball the suggested-actions list -in both light and dark mode. - -## Step 5: Create Triggers (Optional) - -If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. - -### Directory Structure -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel exports -├── utils.ts # Trigger options, setup instructions, extra fields -├── {event_a}.ts # Primary trigger (includes dropdown) -├── {event_b}.ts # Secondary triggers (no dropdown) -└── webhook.ts # Generic webhook (optional) -``` - -### Key Pattern - -```typescript -import { buildTriggerSubBlocks } from '@/triggers' -import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils' - -// Primary trigger - includeDropdown: true -export const {service}EventATrigger: TriggerConfig = { - id: '{service}_event_a', - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_a', - triggerOptions: {service}TriggerOptions, - includeDropdown: true, // Only for primary trigger! - setupInstructions: {service}SetupInstructions('Event A'), - extraFields: build{Service}ExtraFields('{service}_event_a'), - }), - // ... -} - -// Secondary triggers - no dropdown -export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // No includeDropdown! - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - // ... -} -``` - -### Connect to Block -```typescript -import { getTrigger } from '@/triggers' - -export const {Service}Block: BlockConfig = { - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, - subBlocks: [ - // Tool fields... - ...getTrigger('{service}_event_a').subBlocks, - ...getTrigger('{service}_event_b').subBlocks, - ], -} -``` - -See `/add-trigger` skill for complete documentation. - -## Step 6: Register Everything - -### Tools Registry (`apps/sim/tools/registry.ts`) - -```typescript -// Add import (alphabetically) -import { - {service}Action1Tool, - {service}Action2Tool, -} from '@/tools/{service}' - -// Add to tools object (alphabetically) -export const tools: Record = { - // ... existing tools ... - {service}_action1: {service}Action1Tool, - {service}_action2: {service}Action2Tool, -} -``` - -Then regenerate the generated tool metadata and commit it: - -```bash -bun run tool-metadata:generate -``` - -Client code reads `params`/`outputs` from these artifacts rather than importing -the registry, so a tool you add, change or remove is invisible to the UI until they are regenerated, -and CI fails on stale ones. See `.agents/skills/tool-registry-boundary/SKILL.md`. - -### Block Registry (`apps/sim/blocks/registry-maps.ts`) - -The data maps (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`) live in `registry-maps.ts`; `registry.ts` holds only the accessor functions. Add the import and an entry to each map alphabetically: - -```typescript -// Add import (alphabetically) -import { {Service}Block, {Service}BlockMeta } from '@/blocks/blocks/{service}' - -// Add to the config map (alphabetically) -export const BLOCK_REGISTRY: Record = { - // ... existing blocks ... - {service}: {Service}Block, -} - -// Add to the catalog-meta map (alphabetically) -export const BLOCK_META_REGISTRY: Record = { - // ... existing metas ... - {service}: {Service}BlockMeta, -} -``` - -### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist - -```typescript -// Add import (alphabetically) -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY (alphabetically) -export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... - {service}_event_a: {service}EventATrigger, - {service}_event_b: {service}EventBTrigger, - {service}_webhook: {service}WebhookTrigger, -} -``` - -## Step 7: Configure Deployment Availability - -Do this for every visible OAuth integration. API-key and unauthenticated integrations do not need -an OAuth client capability. - -The block's `oauth-input.serviceId` is the canonical link between the generated integration catalog, -the OAuth service configuration, deployment availability, and the setup CLI. - -1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical - service entry in `apps/sim/lib/oauth/oauth.ts`. -2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in - `OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and - Microsoft service IDs deliberately share provider-level capabilities. -3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add - every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the - matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in - `scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer - secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields. -4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to - `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in - `apps/sim/lib/integrations/service-account-metadata.ts`. Use: - - no `deploymentRequirement` when the service-account path works independently of OAuth client fields; - - `'oauth-client'` when it requires the same deployment OAuth client fields; - - `'preview-gated'` when availability is controlled by the service-account preview block. - -Never add a permissive fallback for missing capability metadata. A visible OAuth integration without -a resolvable capability must fail validation. - -## Step 8: Generate and Validate the Catalog - -Run the documentation generator: -```bash -bun run scripts/generate-docs.ts -bun run integration-catalog:check -``` - -This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`). - -The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then -derives the deployment-relevant fields from the executable block registry and compares them with the -committed projection. Review the generated diff and keep only intentional changes. - -## V2 Integration Pattern - -If creating V2 versions (API-aligned outputs): - -1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs -2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector` -3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true` -4. **Registry** - Register both versions - -```typescript -// In registry -{service}: {Service}Block, // V1 (legacy, hidden) -{service}_v2: {Service}V2Block, // V2 (visible) -``` - -## Complete Checklist - -### Tools -- [ ] Created `tools/{service}/` directory -- [ ] Created `types.ts` with all interfaces -- [ ] Created tool file for each operation -- [ ] All params have correct visibility -- [ ] All nullable fields use `?? null` -- [ ] All optional outputs have `optional: true` -- [ ] Created `index.ts` barrel export -- [ ] Registered all tools in `tools/registry.ts` -- [ ] Ran `bun run tool-metadata:generate` and committed the regenerated artifacts -- [ ] Classified every model-visible, opaque, Sim-durable, and internal-execution request field -- [ ] Added shared model-input projection or private provenance only where required; ordinary - external resource locators and control inputs retain their request semantics -- [ ] Confirmed ordinary third-party tool results are not generically sanitized -- [ ] Added provenance compatibility and fail-closed boundary tests where applicable - -### Block -- [ ] Created `blocks/blocks/{service}.ts` -- [ ] Set `integrationType` to the correct `IntegrationType` enum value -- [ ] Set `tags` array with all applicable `IntegrationTag` values -- [ ] Defined operation dropdown with all operations -- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')` -- [ ] Added conditional fields per operation -- [ ] Set up dependsOn for cascading selectors -- [ ] Configured tools.access with all tool IDs -- [ ] Configured tools.config.tool selector -- [ ] Defined outputs matching tool outputs -- [ ] Registered block + meta in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) -- [ ] If triggers: set `triggers.enabled` and `triggers.available` -- [ ] If triggers: spread trigger subBlocks with `getTrigger()` -- [ ] Exported `{Service}BlockMeta` with at least 7 templates - -### OAuth Scopes (if OAuth service) -- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS` -- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` -- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode) -- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode) - -### Deployment Availability (if OAuth service) -- [ ] Block declares exactly one distinct `oauth-input.serviceId` -- [ ] `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry -- [ ] Every new OAuth capability field exists in `apps/sim/lib/core/config/env.ts` -- [ ] Runtime OAuth fields live in `OAUTH_CLIENT_CAPABILITIES`; matching CLI input modes live in the exhaustively checked `OAUTH_CLIENT_SETUP_FIELDS` -- [ ] If `serviceAccountProviderId` is configured, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` has the matching projection and deployment requirement - -### Icon -- [ ] Asked user to provide SVG -- [ ] Added icon to `components/icons.tsx` -- [ ] Icon spreads props correctly -- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons` - -### Triggers (if service supports webhooks) -- [ ] Created `triggers/{service}/` directory -- [ ] Created `utils.ts` with options, instructions, and extra fields helpers -- [ ] Primary trigger uses `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` -- [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] Created `index.ts` barrel export -- [ ] Registered all triggers in `triggers/registry.ts` - -### Docs -- [ ] Ran `bun run scripts/generate-docs.ts` -- [ ] Verified docs file created -- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change -- [ ] `bun run integration-catalog:check` passes - -### Final Validation (Required) -- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs -- [ ] Verified block subBlocks cover all required tool params with correct conditions -- [ ] Verified block outputs match what the tools actually return -- [ ] Verified `tools.config.params` correctly maps and coerces all param types -- [ ] Verified every tool output and `transformResponse` path against documented or live-verified JSON responses -- [ ] If any response schema remained unknown, explicitly told the user instead of guessing -- [ ] `{Service}BlockMeta` exported with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` - -## Example Command - -When the user asks to add an integration: - -``` -User: Add a Stripe integration - -You: I'll add the Stripe integration. Let me: - -1. First, research the Stripe API using Context7 -2. Create the tools for key operations (payments, subscriptions, etc.) -3. Create the block with operation dropdown -4. Register everything -5. Generate docs -6. Ask you for the Stripe icon SVG - -[Proceed with implementation...] - -[After completing steps 1-5...] - -I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe. -You can usually find this in the service's brand/press kit page, or copy it from their website. - -Paste the SVG code here and I'll convert it to a React component. -``` - -## File Handling - -When your integration handles file uploads or downloads, follow these patterns to work with `UserFile` objects consistently. - -### What is a UserFile? - -A `UserFile` is the standard file representation in Sim: - -```typescript -interface UserFile { - id: string // Unique identifier - name: string // Original filename - url: string // Presigned URL for download - size: number // File size in bytes - type: string // MIME type (e.g., 'application/pdf') - base64?: string // Optional base64 content (if small file) - key?: string // Internal storage key - context?: object // Storage context metadata -} -``` - -### File Input Pattern (Uploads) - -For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval. - -#### 1. Block SubBlocks for File Input - -Use the basic/advanced mode pattern: - -```typescript -// Basic mode: File upload UI -{ - id: 'uploadFile', - title: 'File', - type: 'file-upload', - canonicalParamId: 'file', // Maps to 'file' param - placeholder: 'Upload file', - mode: 'basic', - multiple: false, - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -// Advanced mode: Reference from previous block -{ - id: 'fileRef', - title: 'File', - type: 'short-input', - canonicalParamId: 'file', // Same canonical param - placeholder: 'Reference file (e.g., {{file_block.output}})', - mode: 'advanced', - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -``` - -**Critical:** `canonicalParamId` must NOT match any subblock `id`. - -#### 2. Normalize File Input in Block Config - -In `tools.config.tool`, use `normalizeFileInput` to handle all input variants: - -```typescript -import { normalizeFileInput } from '@/blocks/utils' - -tools: { - config: { - tool: (params) => { - // Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile - } - return `{service}_${params.operation}` - }, - }, -} -``` - -#### 3. Create Special Internal Tool Execution Route - -Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. This raw route pattern is only for an integration's provider-execution boundary when it needs special file normalization, large-body handling, or protocol behavior. It is not the pattern for CRUD or other operations on protected Sim resources. For those, use the `migrate-application-operation` skill and an authorized application use case with the ordinary internal/v2 route builders. - -Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/tools/{service}.ts` (or an existing aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. Authenticate and perform cheap admission before parsing or downloading files. - -```typescript -// apps/sim/lib/api/contracts/tools/{service}.ts -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' - -export const {service}UploadBodySchema = z.object({ - accessToken: z.string(), - file: FileInputSchema.optional().nullable(), - fileContent: z.string().optional().nullable(), - // ... other params -}) - -export const {service}UploadResponseSchema = z.object({ - success: z.boolean(), - output: z.object({ id: z.string(), url: z.string() }).optional(), - error: z.string().optional(), -}) - -export const {service}UploadContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/{service}/upload', - body: {service}UploadBodySchema, - response: { mode: 'json', schema: {service}UploadResponseSchema }, -}) - -export type {Service}UploadBody = z.input -export type {Service}UploadResponse = z.output -``` - -```typescript -// apps/sim/app/api/tools/{service}/upload/route.ts -import { createLogger } from '@sim/logger' -import { NextResponse, type NextRequest } from 'next/server' -import { {service}UploadContract } from '@/lib/api/contracts/tools/{service}' -import { parseRequest } from '@/lib/api/server' -import { checkInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { type RawFileInput } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' - -const logger = createLogger('{Service}UploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - // Auth always runs BEFORE parseRequest — never validate untrusted input before authenticating. - const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest({service}UploadContract, request, {}) - if (!parsed.success) return parsed.response - const data = parsed.data.body - - let fileBuffer: Buffer - let fileName: string - - // Prefer UserFile input, fall back to legacy base64 - if (data.file) { - const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) - if (userFiles.length === 0) { - return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 }) - } - const userFile = userFiles[0] - fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) - fileName = userFile.name - } else if (data.fileContent) { - // Legacy: base64 string (backwards compatibility) - fileBuffer = Buffer.from(data.fileContent, 'base64') - fileName = 'file' - } else { - return NextResponse.json({ success: false, error: 'File required' }, { status: 400 }) - } - - // Now call external API with fileBuffer - const response = await fetch('https://api.{service}.com/upload', { - method: 'POST', - headers: { Authorization: `Bearer ${data.accessToken}` }, - body: new Uint8Array(fileBuffer), // Convert Buffer for fetch - }) - - // ... handle response -}) -``` - -#### 4. Update Tool to Use Internal Route - -```typescript -export const {service}UploadTool: ToolConfig = { - id: '{service}_upload', - // ... - params: { - file: { type: 'file', required: false, visibility: 'user-or-llm' }, - fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy - }, - request: { - url: '/api/tools/{service}/upload', // Internal route - method: 'POST', - body: (params) => ({ - accessToken: params.accessToken, - file: params.file, - fileContent: params.fileContent, - }), - }, -} -``` - -### File Output Pattern (Downloads) - -For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects. - -#### In Tool transformResponse - -```typescript -import { FileToolProcessor } from '@/executor/utils/file-tool-processor' - -transformResponse: async (response, context) => { - const data = await response.json() - - // Process file outputs to UserFile objects - const fileProcessor = new FileToolProcessor(context) - const file = await fileProcessor.processFileData({ - data: data.content, // base64 or buffer - mimeType: data.mimeType, - filename: data.filename, - }) - - return { - success: true, - output: { file }, - } -} -``` - -#### In API Route (for complex file handling) - -```typescript -// Return file data that FileToolProcessor can handle -return NextResponse.json({ - success: true, - output: { - file: { - data: base64Content, - mimeType: 'application/pdf', - filename: 'document.pdf', - }, - }, -}) -``` - -### Key Helpers Reference - -| Helper | Location | Purpose | -|--------|----------|---------| -| `normalizeFileInput` | `@/blocks/utils` | Normalize file params in block config | -| `processFilesToUserFiles` | `@/lib/uploads/utils/file-utils` | Convert raw inputs to UserFile[] | -| `downloadFileFromStorage` | `@/lib/uploads/utils/file-utils.server` | Get file Buffer from UserFile | -| `FileToolProcessor` | `@/executor/utils/file-tool-processor` | Process tool output files | -| `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects | -| `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation | - -### Advanced Mode for Optional Fields - -Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings. - -### WandConfig for Complex Inputs - -Use `wandConfig` for fields that are hard to fill out manually: -- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt -- **JSON arrays**: Use `generationType: 'json-object'` for structured data -- **Complex queries**: Use a descriptive prompt explaining the expected format - -```typescript -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.', - generationType: 'timestamp', - }, -} -``` - -### OAuth Scopes (Centralized System) - -Scopes are maintained in a single source of truth and reused everywhere: - -1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes` -2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI -3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils` -4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils` - -**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source. - -```typescript -// In auth.ts (Better Auth config) -scopes: getCanonicalScopesForProvider('{service}'), - -// In block credential sub-block -requiredScopes: getScopesForService('{service}'), -``` - -### Common Gotchas - -1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration -2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values -3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` -4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted -5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true -6. **DependsOn clears options** - When a dependency changes, selector options are refetched -7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility -8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility -9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields -10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled -11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts -12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` -13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability -14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping diff --git a/.claude/commands/add-managed-cli.md b/.claude/commands/add-managed-cli.md deleted file mode 100644 index 4b5bfcd0840..00000000000 --- a/.claude/commands/add-managed-cli.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -description: Add or upgrade a curated, immutable managed CLI for Sim Function sandboxes, including client-safe catalog metadata, a pinned server-only installation recipe, checksum and executable verification, provider compatibility, PATH propagation, content-addressed image identity, and tests. Use when adding a CLI to the Sandbox managed-CLI selector or changing an existing managed CLI version or recipe. ---- - -# Add a Managed CLI - -Add CLIs through the curated registry. Never turn this surface into arbitrary commands or package names: system packages already cover validated Debian/APT coordinates, while managed CLIs require immutable artifacts and reproducible recipes. - -## Read First - -Read these live sources before editing; do not copy their current entries into this skill: - -1. `apps/sim/lib/execution/remote-sandbox/cli-tools.ts` — persisted IDs and client-safe metadata. -2. `apps/sim/lib/execution/remote-sandbox/cli-tools.server.ts` — server-only recipes and recipe helpers. -3. `apps/sim/lib/execution/remote-sandbox/cli-tools.test.ts` — catalog and supply-chain invariants. -4. `apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts` — client/server import boundary. -5. `apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts` — content-addressed hash inputs. - -Read `resolve.ts` and `e2b.ts` only when changing provisioning mechanics. A normal catalog addition should not require UI, API, database, resolver, or provider edits; those paths derive from the registries. - -Do not modify the dedicated Function base image or the separate Mothership Shell template for a normal managed CLI addition. Managed recipes layer on the Function base image. Do not change `MAX_SANDBOX_CLI_TOOLS` from ten unless the user separately requests a product-limit change. - -## 1. Verify the Upstream Release - -Use primary upstream release documentation and official artifacts. Establish all of the following before writing code: - -- Exact version and stable Linux x86-64 artifact URL. Never use `latest`, mutable redirects, or an unversioned installer. -- SHA-256 for the exact artifact. Prefer a publisher-signed checksum; otherwise download the official artifact and compute it independently. -- Archive layout and the exact executable paths to install. -- Noninteractive, credential-free verification commands for every advertised executable, normally version commands. -- Required PATH entries under `/opt/sim-cli`. -- E2B and Daytona compatibility. Default to both only when the same Linux recipe works on both. - -When the user does not name a version, select the current stable upstream release from primary sources and state the exact version chosen. Do not silently choose a prerelease or infer a version from an unverified secondary source. - -Reject curl-to-shell installers, `npm install`, `pip install`, distro package repositories, arbitrary user commands, and artifacts from unofficial mirrors. Never place credentials, tokens, login commands, or account configuration in an image recipe or build log; authentication is runtime-only. - -## 2. Choose an Immutable ID - -Use `@-r`. - -- New upstream version: append a new ID ending in `-r1`. -- Recipe-only change for the same upstream version: append `-r2`, `-r3`, and so on. -- Never mutate or delete an existing ID or recipe. Persisted sandboxes must continue resolving to the bytes and behavior they selected. -- On upgrade, retain the old ID and recipe and set its metadata to `selectable: false`. Only the newest version keeps the public label selectable. - -Before shipping the first upgrade for a tool family, verify that editing a sandbox cannot leave both the retired and replacement IDs selected. If the generic selector and API validation do not already replace or reject colliding versions, address that once at the generic registry boundary with focused UI and contract tests; never special-case the individual CLI or silently install two versions that expose the same executable. - -Recipe identity includes the ID, revision, and SHA-256 in the sandbox image hash. Keeping old entries is what makes that identity reproducible rather than merely cache-busting. - -## 3. Add Client-Safe Metadata - -In `cli-tools.ts`: - -1. Append the ID to `SANDBOX_CLI_TOOL_IDS` in the same order used by the metadata and recipe registries. -2. Add a `SANDBOX_CLI_TOOLS` entry whose key and `id` exactly match. -3. Provide a unique selectable `label`, concise `description`, existing `category`, and useful executable/vendor aliases in `searchTerms`. -4. Add a category only when no existing category is accurate, then ensure it has at least one selectable entry. - -Keep this file safe for client bundles. It must not contain artifact URLs, checksums, install commands, verification commands, PATH recipes, provider SDKs, or imports from `cli-tools.server.ts`. - -The API enum and searchable grouped selector derive from this registry. Do not add parallel option arrays or route-local wire types. - -## 4. Add the Server-Only Recipe - -In `cli-tools.server.ts`, use the narrowest existing helper: - -- `defineBinaryRecipe` for one downloaded binary. -- `defineTarGzipRecipe` or `defineZipRecipe` for archives containing binaries. -- `defineVerifiedRecipe` for a vendor archive or installer layout that needs explicit commands. -- A direct typed entry only when the helpers cannot faithfully model the release. - -Provide every field the recipe contract requires: - -- Exact `version`, `artifactUrl`, `artifactName`, and lowercase 64-character `sha256`. -- Every installed `executable` and a corresponding `verificationCommands` entry. -- Deterministic extraction/install commands into `/opt/sim-cli`; quote fixed paths and clean temporary artifacts. -- `pathEntries` when the executable is not installed into the helper's default `bin` directory. -- `supportedProviders` only when it differs from the E2B-and-Daytona default. -- `revision` when it differs from `1`; it must agree with the ID suffix. - -Verification must prove the command is discoverable through `sandboxCliEnvironment`, not authenticate or contact a user account. Recipe commands run as root during both prebuilt image creation and runtime provisioning. - -If the artifact host is new, add only the exact official hostname to the `officialHosts` allowlist in `cli-tools.test.ts`. Treat that as a supply-chain review, not a way to silence the test. - -## 5. Preserve Generic Behavior - -Confirm the existing generic paths remain sufficient: - -- `sandboxCliToolRecipes` canonicalizes and resolves the recipe. -- `sandboxCliEnvironment` propagates PATH to Python subprocesses, JavaScript subprocesses, and Shell. -- E2B bakes the recipe into the custom image; runtime-strategy providers install it within the Function timeout. -- CLI-only sandboxes remain buildable even with no language packages. -- `hashSandboxSpec` includes recipe ID, revision, and checksum while preserving the legacy hash for an empty CLI list. -- The settings selector derives groups and search aliases from client-safe metadata. - -Do not special-case a CLI in those layers unless the registry contract cannot express a genuine provider requirement. Extend the registry contract generically when multiple CLIs need the same new behavior. - -## 6. Test the Addition - -Extend tests when the new entry introduces behavior not already covered: - -- For every upgrade, add a regression proving the old ID and recipe remain resolvable but non-selectable, while the replacement ID is selectable. -- Add important executable aliases to the table-driven search assertion. -- Add a focused assertion for a multi-executable recipe, custom PATH, or restricted provider. -- Add an opt-in credentialed smoke test only when installation plus a real minimal command cannot be validated without authentication. Read credentials from test-only environment variables, skip by default, create them only at runtime, and always tear down the sandbox. - -Never commit downloaded artifacts or credentials. - -## Required Validation - -From `apps/sim`: - -```bash -bunx vitest run \ - lib/execution/remote-sandbox/cli-tools.test.ts \ - lib/execution/remote-sandbox/cli-tools-boundary.test.ts \ - lib/execution/remote-sandbox/sandbox-spec.test.ts \ - lib/execution/remote-sandbox/resolve.test.ts \ - lib/api/contracts/sandboxes.test.ts \ - 'app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts' \ - 'app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.test.tsx' -``` - -From the repository root: - -```bash -bun run type-check -bun run check:api-validation -bunx biome check \ - apps/sim/lib/execution/remote-sandbox/cli-tools.ts \ - apps/sim/lib/execution/remote-sandbox/cli-tools.server.ts \ - apps/sim/lib/execution/remote-sandbox/cli-tools.test.ts -git diff --check -``` - -For a new recipe, also exercise its install and every verification command in an actual E2B or Daytona sandbox when credentials and network access are available. Report clearly when only registry/unit validation ran. - -## Completion Checklist - -- [ ] Official immutable Linux x86-64 artifact and SHA-256 verified. -- [ ] Versioned ID appended; old IDs and recipes retained. -- [ ] Client metadata is searchable, categorized, unique, and recipe-free. -- [ ] Server recipe is pinned, integrity-checked, noninteractive, and credential-free. -- [ ] Every advertised executable has an offline verification command and PATH entry. -- [ ] Provider compatibility is explicit and accurate. -- [ ] Catalog, boundary, hash, resolver, type, API-validation, format, and diff checks pass. -- [ ] Real provider installation was tested, or the missing live verification is disclosed. diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md deleted file mode 100644 index 9cef8634689..00000000000 --- a/.claude/commands/add-model.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -description: Add a new LLM model to apps/sim/providers/models.ts with specs verified against the provider's live API docs (no hallucination) -argument-hint: [docs-url] ---- - -# Add Model Skill - -You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and capability claim MUST be derived from a live web fetch of the provider's official docs in this session.** Marketing emails, training data, and your prior knowledge are not sources of truth — they routinely hallucinate pricing, context windows, and capability lists. - -## Hard rules (do not skip) - -1. **Live-fetch or refuse.** Before writing the entry, you must successfully WebFetch the provider's official models/pricing page in this session. If you cannot reach an authoritative source for any field, **mark the field as UNVERIFIED in your report and ask the user before guessing**. Never fill in pricing or capabilities from memory. -2. **Two-source rule for pricing.** Cross-check input/output/cached pricing against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice, mem0, intuitionlabs). If sources disagree, the provider's own docs win — but flag the disagreement. -3. **Read the code before setting capability flags.** Capability flags are dead unless the provider's implementation under `apps/sim/providers/{provider}/` actually consumes them (see Consumption Matrix below). Setting a flag the provider ignores is a silent bug. -4. **Cite every fact.** Your final report must list the URL each value came from. No URL → not verified. - -## Your Task - -1. Identify provider and model id from user args -2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source -3. Apply the Consumption Matrix to know which capability flags are real -4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly -5. Check the repo-side touchpoints that are NOT data-driven (hosted-key billing, tests, provider code) -6. Insert the entry, run `bun run lint`, print the verification report - -## Step 1: Live source-of-truth lookup - -In priority order — fetch all that exist for the provider: - -| Provider | Models index | Pricing | Reasoning/parameter caveats | -|---|---|---|---| -| OpenAI | platform.openai.com/docs/models | openai.com/api/pricing | platform.openai.com/docs/guides/reasoning | -| Anthropic | docs.anthropic.com/en/docs/about-claude/models | anthropic.com/pricing | docs.anthropic.com/en/docs/build-with-claude/extended-thinking | -| Google (Gemini) | ai.google.dev/gemini-api/docs/models | ai.google.dev/pricing | ai.google.dev/gemini-api/docs/thinking | -| xAI | docs.x.ai/developers/models | docs.x.ai/developers/models (per-model detail page) | docs.x.ai/developers/model-capabilities/text/reasoning | -| Mistral | docs.mistral.ai/getting-started/models/models_overview | mistral.ai/pricing | n/a | -| DeepSeek | api-docs.deepseek.com/quick_start/pricing | same | api-docs.deepseek.com/guides/reasoning_model | -| Groq | console.groq.com/docs/models | groq.com/pricing | n/a | -| Cerebras | inference-docs.cerebras.ai/models | cerebras.ai/pricing | n/a | - -Secondary verification (use at least one): `openrouter.ai//`, `artificialanalysis.ai/models/`, `cloudprice.net/models/-`. - -Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, context window in tokens, input price per 1M, cached input price per 1M, output price per 1M, max output tokens, supported reasoning effort levels, accepted parameters (temperature, top_p), release date. Do not fill in fields you cannot find."* - -## Step 2: Consumption Matrix (which provider honors which capability) - -| Capability | Honored by | Effect if set elsewhere | -|---|---|---| -| `temperature` | All providers (passed through if set) | Safe but inert on always-reasoning models that reject it | -| `toolUsageControl` | All providers (provider-level, not per-model) | n/a — set on `ProviderDefinition`, not models | -| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | -| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | -| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | -| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | -| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | -| `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | -| `computerUse` | `anthropic/core.ts` | Dead elsewhere | -| `deepResearch` | UI flag for routing to deep-research SKUs | Set only on actual deep-research model IDs | -| `memory: false` | Conversation persistence opt-out | Set only when model genuinely cannot maintain history (e.g., deep-research) | - -**Always re-grep before relying on this table** — the codebase moves: - -```bash -rg "reasoningEffort|reasoning_effort" apps/sim/providers// -rg "verbosity" apps/sim/providers// -rg "request\.thinking|thinking:" apps/sim/providers// -rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// -``` - -## Step 3: Match the provider's existing entry pattern - -Open `apps/sim/providers/models.ts`, find `PROVIDER_DEFINITIONS[].models`, read 2-3 sibling entries. Match field order exactly: - -```ts -{ - id: '', - pricing: { - input: , - cachedInput: , // omit if provider doesn't offer caching - output: , - updatedAt: '', - }, - capabilities: { - // only flags the provider actually consumes — see matrix - }, - contextWindow: , - releaseDate: '', - recommended: true, // only if new flagship; ask user before swapping - speedOptimized: true, // only on smallest/fastest tier - deprecated: true, // only on retired models -} -``` - -### Reseller providers (azure-openai, azure-anthropic, vertex, bedrock, openrouter) - -Model id MUST be prefixed: `azure/`, `azure-anthropic/`, `vertex/`, `bedrock/`, `openrouter/`. Pricing usually mirrors the upstream provider but verify on the reseller's own pricing page. - -### Insertion order - -Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5.4 above GPT-5.2). Across families, biggest/flagship at top of list. - -### `recommended` / `speedOptimized` - -- At most one or two `recommended: true` per provider — the current flagship(s). -- If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it. -- `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class). - -## Step 4: Repo-side touchpoints beyond the entry - -Adding the `models.ts` entry is most of the job because nearly every consumer is **data-driven** and picks the model up automatically: the ~40 query helpers in `models.ts` / `providers/utils.ts`, the public `/models` catalog (`app/(landing)/models/utils.ts` iterates `PROVIDER_DEFINITIONS`), the agent-block model dropdown, and copilot's `isKnownModelId` / `suggestModelIdsForUnknownModel` validation. The touchpoints below are the exceptions — they are **not** data-driven, so check each one. - -### Hosted = auto-billed, by provider - -`getHostedModels()` in `apps/sim/providers/models.ts` returns **every** model under `openai`, `anthropic`, and `google`: - -```ts -export function getHostedModels(): string[] { - return [ - ...getProviderModels('openai'), - ...getProviderModels('anthropic'), - ...getProviderModels('google'), - ] -} -``` - -So a model added to any of those three providers is **automatically served with Sim's rotating hosted key and billed** to the workspace via `shouldBillModelUsage()` (`providers/utils.ts`). Before you insert: - -- **If the model should be BYOK-only / never-billed**, do NOT drop it under `openai`/`anthropic`/`google` as-is — that silently enrolls it in hosted billing. Confirm hosting/billing intent with the user. (Precedent: Ollama Cloud is a deliberately separate `isReseller` provider specifically to stay BYOK-only/never-billed.) -- **If the model should be hosted**, the deployment must actually have a key for it — the provider's `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars must be set, or hosted runs fail at execution time. -- State the hosted/billing status explicitly in the verification report. - -### Tests with hardcoded model IDs - -`bun run lint` does **not** run tests. A few tests assert specific model IDs and can break or need updating when you touch a hosted or flagship model: - -- `apps/sim/providers/utils.test.ts` — asserts membership of `getHostedModels()` / `shouldBillModelUsage()` -- `apps/sim/providers/index.test.ts` and serializer tests — reference concrete model IDs - -```bash -rg "|getHostedModels|shouldBillModelUsage" apps/sim/providers/*.test.ts -``` - -If anything matches, run the affected provider tests and update assertions as needed. - -### New API behavior is NOT data-driven - -The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). - -### Thinking/reasoning models: `streamed` visibility + generated docs - -If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: - -- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. -- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. -- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. -- Include the `streamed` value (with its source URL) in the verification report when set. - -### Wrong family entirely? - -- **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. -- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//` (assemble streaming responses with `createStreamingExecution` and wrap tool schemas with the `@/providers/tool-schema-adapter` helpers), an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. - -## Step 5: Write, lint - -```bash -bun run lint -bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort -``` - -Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. - -## Step 6: Verification report (mandatory format) - -End with this exact structure: - -```markdown -### Verification — - -| Field | Value | Source URL | Status | -|---|---|---|---| -| `id` | `grok-4.3` | https://docs.x.ai/... | ✓ verified | -| `contextWindow` | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified (2 sources agree) | -| `input` | $1.25/M | https://docs.x.ai/... | ✓ verified | -| `cachedInput` | $0.20/M | https://cloudprice.net/... | ⚠️ single source | -| `output` | $2.50/M | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified | -| `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only | -| `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted | -| `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified | -| hosted/billing | BYOK-only (xai not in `getHostedModels`) | `providers/models.ts` | — confirmed intent | - -**Disagreements** -- _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_ - -**Unverified fields** -- _none_ OR _: could not find authoritative source — left as based on sibling pattern; please confirm_ -``` - -If any row is ⚠️ single-source or "unverified," **state it plainly to the user and ask whether to proceed**. Do not silently merge. - -## What to do if you cannot find a source - -Omitting a field is **not the same as verifying it**. Any field you cannot confirm from a live fetch must be **both** omitted from the entry **and** listed as ❓ UNVERIFIED in the report's "Unverified fields" section, with the URLs you attempted. Then ask the user to confirm before merging. - -- Pricing missing → do NOT guess. Omit `cachedInput`. Mark ❓ UNVERIFIED. Ask the user for the price or the docs URL. -- Context window missing → do NOT guess. Ask the user; mark ❓ UNVERIFIED. -- Release date missing → omit the field; mark ❓ UNVERIFIED in the report. -- Capability uncertain → omit the flag (safer than setting a dead/wrong one); mark ❓ UNVERIFIED so the user knows you didn't confirm it either way. - -## Anti-patterns this skill exists to prevent - -- ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) -- ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) -- ❌ Setting `thinking` on non-Anthropic/non-Gemini providers -- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model -- ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x -- ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date -- ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) -- ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship -- ❌ Adding a BYOK-only model under `openai`/`anthropic`/`google` (silently enrolls it in hosted billing via `getHostedModels()`) -- ❌ Reporting "done" after only `bun run lint` when you touched a hosted (openai/anthropic/google) or flagship model with assertions in `providers/utils.test.ts` -- ❌ Reporting "done" with any UNVERIFIED row in the table diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md deleted file mode 100644 index 6b390520b64..00000000000 --- a/.claude/commands/add-tools.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -description: Create tool configurations for a Sim integration by reading API docs -argument-hint: [api-docs-url] ---- - -# Add Tools Skill - -You are an expert at creating tool configurations for Sim integrations. Your job is to read API documentation and create properly structured tool files. - -## Your Task - -When the user asks you to create tools for a service: -1. Use Context7 or WebFetch to read the service's API documentation -2. Create the tools directory structure -3. Generate properly typed tool configurations - -## Hard Rule: No Guessed Response Schemas - -If the docs do not clearly show the response JSON for a tool, you MUST tell the user exactly which outputs are unknown and stop short of guessing. - -- Do NOT invent response field names -- Do NOT infer nested paths from nearby endpoints -- Do NOT guess array item shapes -- Do NOT write `transformResponse` against unverified payloads - -If the response shape is unknown, do one of these instead: -1. Ask the user for sample responses -2. Ask the user for test credentials so you can verify live responses -3. Implement only the endpoints whose outputs are documented -4. Leave the tool unimplemented and explicitly say why - -## Directory Structure - -Create files in `apps/sim/tools/{service}/`: -``` -tools/{service}/ -├── index.ts # Barrel export -├── types.ts # Parameter & response types -└── {action}.ts # Individual tool files (one per operation) -``` - -## Tool Configuration Structure - -Every tool MUST follow this exact structure: - -```typescript -import type { {ServiceName}{Action}Params } from '@/tools/{service}/types' -import type { ToolConfig } from '@/tools/types' - -interface {ServiceName}{Action}Response { - success: boolean - output: { - // Define output structure here - } -} - -export const {serviceName}{Action}Tool: ToolConfig< - {ServiceName}{Action}Params, - {ServiceName}{Action}Response -> = { - id: '{service}_{action}', // snake_case, matches tool name - name: '{Service} {Action}', // Human readable - description: 'Brief description', // One sentence - version: '1.0.0', - - // OAuth config (if service uses OAuth) - oauth: { - required: true, - provider: '{service}', // Must match OAuth provider ID - }, - - params: { - // Hidden params (system-injected, only use hidden for oauth accessToken) - accessToken: { - type: 'string', - required: true, - visibility: 'hidden', - description: 'OAuth access token', - }, - // User-only params (credentials, api key, IDs user must provide) - someId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'The ID of the resource', - }, - // User-or-LLM params (everything else, can be provided by user OR computed by LLM) - query: { - type: 'string', - required: false, // Use false for optional - visibility: 'user-or-llm', - description: 'Search query', - }, - }, - - request: { - url: (params) => `https://api.service.com/v1/resource/${params.id}`, - method: 'POST', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - }), - body: (params) => ({ - // Request body - only for POST/PUT/PATCH - // Trim ID fields to prevent copy-paste whitespace errors: - // userId: params.userId?.trim(), - }), - }, - - transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - // Map API response to output - // Use ?? null for nullable fields - // Use ?? [] for optional arrays - }, - } - }, - - outputs: { - // Define each output field - }, -} -``` - -## Critical Rules for Parameters - -### Visibility Options -- `'hidden'` - System-injected (OAuth tokens, internal params). User never sees. -- `'user-only'` - User must provide (credentials, api keys, account-specific IDs) -- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters, most fall into this category) - -### Parameter Types -- `'string'` - Text values -- `'number'` - Numeric values -- `'boolean'` - True/false -- `'json'` - Complex objects (NOT 'object', use 'json') -- `'file'` - Single file -- `'file[]'` - Multiple files - -### Required vs Optional -- Always explicitly set `required: true` or `required: false` -- Optional params should have `required: false` - -## Resolved Secrets and Provenance Boundaries - -- Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only - when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. -- Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. -- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact - field is proven model-visible. For serialized external model content, project the serialized - top-level param through `request.modelInput` before the existing formatter parses it; do not add a - separate hard-rejection mechanism. -- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or - `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, - path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at - the owning model-egress boundary. Authenticate first, validate the exact selection and scope, - strip the private envelope, then import or propagate provenance at the receiving boundary. - Preserve documented headerless legacy behavior. -- Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private - headers, or blanket-sanitize tool results. -- Add focused tests for named projection, identical unproven public text, malformed/incomplete - metadata, metadata stripping, scope isolation, and legacy compatibility where applicable. - -## Critical Rules for Outputs - -### Output Types -- `'string'`, `'number'`, `'boolean'` - Primitives -- `'json'` - Complex objects (use this, NOT 'object') -- `'array'` - Arrays with `items` property -- `'object'` - Objects with `properties` property - -### Optional Outputs -Add `optional: true` for fields that may not exist in the response: -```typescript -closedAt: { - type: 'string', - description: 'When the issue was closed', - optional: true, -}, -``` - -### Typed JSON Outputs - -When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available: - -```typescript -// BAD: Opaque json with no info about what's inside -metadata: { - type: 'json', - description: 'Response metadata', -}, - -// GOOD: Define the known properties -metadata: { - type: 'json', - description: 'Response metadata', - properties: { - id: { type: 'string', description: 'Unique ID' }, - status: { type: 'string', description: 'Current status' }, - count: { type: 'number', description: 'Total count' }, - }, -}, -``` - -For arrays of objects, define the item structure: -```typescript -items: { - type: 'array', - description: 'List of items', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Item ID' }, - name: { type: 'string', description: 'Item name' }, - }, - }, -}, -``` - -Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown. - -If the response shape is unknown because the docs do not provide it, you MUST tell the user and stop. Unknown is not the same as dynamic. Never guess outputs. - -## Critical Rules for transformResponse - -### Handle Nullable Fields -ALWAYS use `?? null` for fields that may be undefined: -```typescript -transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - id: data.id, - title: data.title, - body: data.body ?? null, // May be undefined - assignee: data.assignee ?? null, // May be undefined - labels: data.labels ?? [], // Default to empty array - closedAt: data.closed_at ?? null, // May be undefined - }, - } -} -``` - -### Never Output Raw JSON Dumps -DON'T do this: -```typescript -output: { - data: data, // BAD - raw JSON dump -} -``` - -DO this instead - extract meaningful fields: -```typescript -output: { - id: data.id, - name: data.name, - status: data.status, - metadata: { - createdAt: data.created_at, - updatedAt: data.updated_at, - }, -} -``` - -## Types File Pattern - -Create `types.ts` with interfaces for all params and responses: - -```typescript -import type { ToolResponse } from '@/tools/types' - -// Parameter interfaces -export interface {Service}{Action}Params { - accessToken: string - requiredField: string - optionalField?: string -} - -// Response interfaces (extend ToolResponse) -export interface {Service}{Action}Response extends ToolResponse { - output: { - field1: string - field2: number - optionalField?: string | null - } -} -``` - -## Index.ts Barrel Export Pattern - -```typescript -// Export all tools -export { serviceTool1 } from './{action1}' -export { serviceTool2 } from './{action2}' - -// Export types -export * from './types' -``` - -## Registering Tools - -After creating tools: -1. Import tools in `apps/sim/tools/registry.ts` -2. Add to the `tools` object with snake_case keys (alphabetically): -```typescript -import { serviceActionTool } from '@/tools/{service}' - -export const tools = { - // ... existing tools ... - {service}_{action}: serviceActionTool, -} -``` - -3. Regenerate the tool metadata artifacts: - -```bash -bun run tool-metadata:generate -``` - -Client code reads a tool's `params`/`outputs` from generated metadata rather than -importing the registry, so a tool you add, change or remove is invisible to the UI until -these are regenerated — and CI fails on stale artifacts. Commit the result. See -`.agents/skills/tool-registry-boundary/SKILL.md`. - -## Wiring Tools into the Block (Required) - -After registering in `tools/registry.ts`, you MUST also update the block definition at `apps/sim/blocks/blocks/{service}.ts`. This is not optional — tools are only usable from the UI if they are wired into the block. - -### 1. Add to `tools.access` - -```typescript -tools: { - access: [ - // existing tools... - 'service_new_action', // Add every new tool ID here - ], - config: { ... } -} -``` - -### 2. Add operation dropdown options - -If the block uses an operation dropdown, add an option for each new tool: - -```typescript -{ - id: 'operation', - type: 'dropdown', - options: [ - // existing options... - { label: 'New Action', id: 'new_action' }, // id maps to what tools.config.tool returns - ], -} -``` - -### 3. Add subBlocks for new tool params - -For each new tool, add subBlocks covering all its required params (and optional ones where useful). Apply `condition` to show them only for the right operation, and mark required params with `required`: - -```typescript -// Required param for new_action -{ - id: 'someParam', - title: 'Some Param', - type: 'short-input', - placeholder: 'e.g., value', - condition: { field: 'operation', value: 'new_action' }, - required: { field: 'operation', value: 'new_action' }, -}, -// Optional param — put in advanced mode -{ - id: 'optionalParam', - title: 'Optional Param', - type: 'short-input', - condition: { field: 'operation', value: 'new_action' }, - mode: 'advanced', -}, -``` - -### 4. Update `tools.config.tool` - -Ensure the tool selector returns the correct tool ID for every new operation. The simplest pattern: - -```typescript -tool: (params) => `service_${params.operation}`, -// If operation dropdown IDs already match tool IDs, this requires no change. -``` - -If the dropdown IDs differ from tool IDs, add explicit mappings: - -```typescript -tool: (params) => { - const map: Record = { - new_action: 'service_new_action', - // ... - } - return map[params.operation] ?? `service_${params.operation}` -}, -``` - -### 5. Update `tools.config.params` - -Add any type coercions needed for new params (runs at execution time, after variable resolution): - -```typescript -params: (params) => { - const result: Record = {} - if (params.limit != null && params.limit !== '') result.limit = Number(params.limit) - if (params.newParamName) result.toolParamName = params.newParamName // rename if IDs differ - return result -}, -``` - -### 6. Add new outputs - -Add any new fields returned by the new tools to the block `outputs`: - -```typescript -outputs: { - // existing outputs... - newField: { type: 'string', description: 'Description of new field' }, -} -``` - -### 7. Add new inputs - -Add new subBlock param IDs to the block `inputs` section: - -```typescript -inputs: { - // existing inputs... - someParam: { type: 'string', description: 'Param description' }, - optionalParam: { type: 'string', description: 'Optional param description' }, -} -``` - -### Block wiring checklist - -- [ ] New tool IDs added to `tools.access` -- [ ] Operation dropdown has an option for each new tool -- [ ] SubBlocks cover all required params for each new tool -- [ ] SubBlocks have correct `condition` (only show for the right operation) -- [ ] Optional/rarely-used params set to `mode: 'advanced'` -- [ ] `tools.config.tool` returns correct ID for every new operation -- [ ] `tools.config.params` handles any ID remapping or type coercions -- [ ] New outputs added to block `outputs` -- [ ] New params added to block `inputs` - -## V2 Tool Pattern - -If creating V2 tools (API-aligned outputs), use `_v2` suffix: -- Tool ID: `{service}_{action}_v2` -- Variable name: `{action}V2Tool` -- Version: `'2.0.0'` -- Outputs: Flat, API-aligned (no content/metadata wrapper) - -## Naming Convention - -All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs. - -## Checklist Before Finishing - -- [ ] All tool IDs use snake_case -- [ ] All params have explicit `required: true` or `required: false` -- [ ] All params have appropriate `visibility` -- [ ] All nullable response fields use `?? null` -- [ ] All optional outputs have `optional: true` -- [ ] No raw JSON dumps in outputs -- [ ] Types file has all interfaces -- [ ] Index.ts exports all tools and re-exports types (`export * from './types'`) -- [ ] Tools registered in `tools/registry.ts` -- [ ] `bun run tool-metadata:generate` run and the regenerated artifacts committed -- [ ] Block wired: `tools.access`, dropdown options, subBlocks, `tools.config`, outputs, inputs -- [ ] Model, durable-storage, and internal-execution boundaries use the shared provenance mechanisms - only where a concrete Sim `{{...}}` resolution path requires them -- [ ] Ordinary third-party inputs/results remain unchanged and private metadata never leaves Sim - -## Final Validation (Required) - -After creating all tools, you MUST validate every tool before finishing: - -1. **Read every tool file** you created — do not skip any -2. **Cross-reference with the API docs** to verify: - - All required params are marked `required: true` - - All optional params are marked `required: false` - - Param types match the API (string, number, boolean, json) - - Request URL, method, headers, and body match the API spec - - `transformResponse` extracts the correct fields from the API response - - All output fields match what the API actually returns - - No fields are missing from outputs that the API provides - - No extra fields are defined in outputs that the API doesn't return - - Every output field and JSON path is backed by docs or live-verified sample responses -3. **Verify consistency** across tools: - - Shared types in `types.ts` match all tools that use them - - Tool IDs in the barrel export match the tool file definitions - - Error handling is consistent (error checks, meaningful messages) -4. **If any response schema is still unknown**, explicitly tell the user instead of guessing diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md deleted file mode 100644 index 45065c69f1d..00000000000 --- a/.claude/commands/add-trigger.md +++ /dev/null @@ -1,512 +0,0 @@ ---- -description: Create webhook or polling triggers for a Sim integration -argument-hint: ---- - -# Add Trigger - -You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks. - -## Your Task - -1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling -2. Create the trigger files using the generic builder (webhook) or manual config (polling) -3. Create a provider handler (webhook) or polling handler (polling) -4. Register triggers and connect them to the block - -## Hard Rule: No Guessed Webhook Payload Schemas - -If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or `formatInput` mappings. - -- Do NOT invent payload field names -- Do NOT guess nested event object paths -- Do NOT infer output fields from the UI or marketing docs -- Do NOT write `formatInput` against unverified webhook bodies - -If the payload shape is unknown, do one of these instead: -1. Ask the user for sample webhook payloads -2. Ask the user for a test webhook source so you can inspect a real event -3. Implement only the event registration/setup portions whose payloads are documented -4. Leave the trigger unimplemented and explicitly say which payload fields are unknown - -## Directory Structure - -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) -├── {event_a}.ts # Primary trigger (includes dropdown) -├── {event_b}.ts # Secondary trigger (no dropdown) -└── webhook.ts # Generic webhook trigger (optional, for "all events") - -apps/sim/lib/webhooks/ -├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) -├── providers/ -│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) -│ ├── types.ts # WebhookProviderHandler interface -│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -│ └── registry.ts # Handler map + default handler -``` - -## Step 1: Create `utils.ts` - -This file contains all service-specific helpers used by triggers. - -```typescript -import type { SubBlockConfig } from '@/blocks/types' -import type { TriggerOutput } from '@/triggers/types' - -export const {service}TriggerOptions = [ - { label: 'Event A', id: '{service}_event_a' }, - { label: 'Event B', id: '{service}_event_b' }, -] - -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Copy the Webhook URL above', - 'Go to {Service} Settings > Webhooks', - `Select the ${eventType} event type`, - 'Paste the webhook URL and save', - 'Click "Save" above to activate your trigger', - ] - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} - -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] -} - -export function build{Service}Outputs(): Record { - return { - eventType: { type: 'string', description: 'The type of event' }, - resourceId: { type: 'string', description: 'ID of the affected resource' }, - resource: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - }, - } -} -``` - -## Step 2: Create Trigger Files - -**Primary trigger** — MUST include `includeDropdown: true`: - -```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -export const {service}EventATrigger: TriggerConfig = { - id: '{service}_event_a', - name: '{Service} Event A', - provider: '{service}', - description: 'Trigger workflow when Event A occurs', - version: '1.0.0', - icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_a', - triggerOptions: {service}TriggerOptions, - includeDropdown: true, - setupInstructions: {service}SetupInstructions('Event A'), - extraFields: build{Service}ExtraFields('{service}_event_a'), - }), - outputs: build{Service}Outputs(), - webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, -} -``` - -**Secondary triggers** — NO `includeDropdown` (it's already in the primary): - -```typescript -export const {service}EventBTrigger: TriggerConfig = { - // Same as above but: id: '{service}_event_b', no includeDropdown -} -``` - -## Step 3: Register and Wire - -### `apps/sim/triggers/{service}/index.ts` - -```typescript -export { {service}EventATrigger } from './event_a' -export { {service}EventBTrigger } from './event_b' -``` - -### `apps/sim/triggers/registry.ts` - -```typescript -import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' - -export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing ... - {service}_event_a: {service}EventATrigger, - {service}_event_b: {service}EventBTrigger, -} -``` - -### Block file (`apps/sim/blocks/blocks/{service}.ts`) - -Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed: - -1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array -2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]` - -```typescript -import { getTrigger } from '@/triggers' - -export const {Service}Block: BlockConfig = { - // ... - subBlocks: [ - // Regular tool subBlocks first... - ...getTrigger('{service}_event_a').subBlocks, - ...getTrigger('{service}_event_b').subBlocks, - ], - // ... tools, inputs, outputs ... - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, -} -``` - -**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1: - -- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically. -- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it. -- **Single block, no V2** (e.g., Google Drive): Add trigger directly. - -`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script. - -## Provider Handler - -All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. - -### When to Create a Handler - -| Behavior | Method | Examples | -|---|---|---| -| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | -| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | -| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | -| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | -| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | -| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | -| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | -| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | -| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | - -If none apply, you don't need a handler. The default handler provides bearer token auth. - -### Example Handler - -```typescript -import crypto from 'crypto' -import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' -import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' - -const logger = createLogger('WebhookProvider:{Service}') - -function validate{Service}Signature(secret: string, signature: string, body: string): boolean { - if (!secret || !signature || !body) return false - const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - return safeCompare(computed, signature) -} - -export const {service}Handler: WebhookProviderHandler = { - verifyAuth: createHmacVerifier({ - configKey: 'webhookSecret', - headerName: 'X-{Service}-Signature', - validateFn: validate{Service}Signature, - providerLabel: '{Service}', - }), - - async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { - const triggerId = providerConfig.triggerId as string | undefined - if (triggerId && triggerId !== '{service}_webhook') { - const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') - if (!is{Service}EventMatch(triggerId, body as Record)) return false - } - return true - }, - - async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record - return { - input: { - eventType: b.type, - resourceId: (b.data as Record)?.id || '', - resource: b.data, - }, - } - }, - - extractIdempotencyId(body: unknown) { - const obj = body as Record - return obj.id && obj.type ? `${obj.type}:${obj.id}` : null - }, -} -``` - -### Register the Handler - -In `apps/sim/lib/webhooks/providers/registry.ts`: - -```typescript -import { {service}Handler } from '@/lib/webhooks/providers/{service}' - -const PROVIDER_HANDLERS: Record = { - // ... existing (alphabetical) ... - {service}: {service}Handler, -} -``` - -## Output Alignment (Critical) - -There are two sources of truth that **MUST be aligned**: - -1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) -2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data - -If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. - -**Rules for `formatInput`:** -- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly -- Return `{ input: ..., skip: { message: '...' } }` to skip execution -- No wrapper objects or duplication -- Use `null` for missing optional data - -## Automatic Webhook Registration - -If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. - -```typescript -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' -import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' - -export const {service}Handler: WebhookProviderHandler = { - async createSubscription(ctx: SubscriptionContext): Promise { - const config = getProviderConfig(ctx.webhook) - const apiKey = config.apiKey as string - if (!apiKey) throw new Error('{Service} API Key is required.') - - const res = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), - }) - - if (!res.ok) throw new Error(`{Service} error: ${res.status}`) - const { id } = (await res.json()) as { id: string } - return { providerConfigUpdates: { externalId: id } } - }, - - async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { - const config = getProviderConfig(ctx.webhook) - const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } - if (!apiKey || !externalId) return - await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` }, - }).catch(() => {}) - }, -} -``` - -**Key points:** -- Throw from `createSubscription` — orchestration rolls back the DB webhook -- Never throw from `deleteSubscription` — log non-fatally -- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` -- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` - -## Trigger Outputs Schema - -Trigger outputs use the same schema as block outputs (NOT tool outputs). - -**Supported:** `type` + `description` for leaf fields, nested objects for complex data. -**NOT supported:** `optional: true`, `items` (those are tool-output-only features). - -```typescript -export function buildOutputs(): Record { - return { - eventType: { type: 'string', description: 'Event type' }, - timestamp: { type: 'string', description: 'When it occurred' }, - payload: { type: 'json', description: 'Full event payload' }, - resource: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - }, - } -} -``` - -## Polling Triggers - -Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually. - -### Directory Structure - -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel export -└── poller.ts # TriggerConfig with polling: true - -apps/sim/lib/webhooks/polling/ -└── {service}.ts # PollingProviderHandler implementation -``` - -### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) - -```typescript -import { pollingIdempotency } from '@/lib/core/idempotency/service' -import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' -import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' -import { processPolledWebhookEvent } from '@/lib/webhooks/processor' - -export const {service}PollingHandler: PollingProviderHandler = { - provider: '{service}', - label: '{Service}', - - async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { - const { webhookData, workflowData, requestId, logger } = ctx - const webhookId = webhookData.id - - try { - // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) - const config = webhookData.providerConfig as unknown as {Service}WebhookConfig - - // First poll: seed state, emit nothing - if (!config.lastCheckedTimestamp) { - await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger) - await markWebhookSuccess(webhookId, logger) - return 'success' - } - - // Fetch changes since last poll, process with idempotency - // ... - - await markWebhookSuccess(webhookId, logger) - return 'success' - } catch (error) { - logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) - await markWebhookFailed(webhookId, logger) - return 'failure' - } - }, -} -``` - -**Key patterns:** -- First poll seeds state and emits nothing (avoids flooding with existing data) -- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup -- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow -- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state -- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew - -### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) - -```typescript -import { {Service}Icon } from '@/components/icons' -import type { TriggerConfig } from '@/triggers/types' - -export const {service}PollingTrigger: TriggerConfig = { - id: '{service}_poller', - name: '{Service} Trigger', - provider: '{service}', - description: 'Triggers when ...', - version: '1.0.0', - icon: {Service}Icon, - polling: true, // REQUIRED — routes to polling infrastructure - - subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, - // ... service-specific config fields (dropdowns, inputs, switches) ... - { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, - ], - - outputs: { - // Must match the payload shape from processPolledWebhookEvent - }, -} -``` - -### Registration (3 places) - -1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set -2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS` -3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY` - -### Helm Cron Job - -Add to `helm/sim/values.yaml` under the existing polling cron jobs: - -```yaml -{service}WebhookPoll: - schedule: "*/1 * * * *" - concurrencyPolicy: Forbid - url: "http://sim:3000/api/webhooks/poll/{service}" -``` - -### Reference Implementations - -- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts` -- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts` -- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` -- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` - -## Checklist - -### Trigger Definition -- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders -- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT -- [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] Created `index.ts` barrel export - -### Registration -- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` -- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` - -### Provider Handler (if needed) -- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` -- [ ] Registered in `providers/registry.ts` (alphabetical) -- [ ] Signature validator is a private function inside the handler file -- [ ] `formatInput` output keys match trigger `outputs` exactly -- [ ] Event matching uses dynamic `await import()` for trigger utils - -### Auto Registration (if supported) -- [ ] `createSubscription` and `deleteSubscription` on the handler -- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` -- [ ] API key field uses `password: true` - -### Polling Trigger (if applicable) -- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts` -- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`) -- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry -- [ ] First poll seeds state and emits nothing -- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts` -- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts` -- [ ] Added cron job to `helm/sim/values.yaml` -- [ ] Payload shape matches trigger `outputs` schema - -### Testing -- [ ] `bun run type-check` passes -- [ ] Manually verify output keys match trigger `outputs` keys -- [ ] Trigger UI shows correctly in the block diff --git a/.claude/commands/babysit.md b/.claude/commands/babysit.md deleted file mode 100644 index 0c75a25a962..00000000000 --- a/.claude/commands/babysit.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, keeps it mergeable against staging, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean ---- - -# Babysit PRs - -Owns a PR end-to-end through review: ship it, wait for the automatic review round, and if it -isn't already clean, drive fix → reply → resolve → re-review cycles until Greptile reports 5/5 -and there are zero open comment threads, keeping the branch mergeable against staging along the -way. Designed to be run under `/loop` (no fixed interval — let it self-pace on review latency) -so it survives across multiple wakeups in the same session. - -## When to use - -- The user says "babysit this PR", "keep working the reviews until it's clean", or similar -- As the natural follow-up to `/ship` when the user wants the review loop automated rather than - manually re-triggering reviews and answering comments themselves - -## Inputs - -Needs a PR number. If none is given and there's no open PR for the current branch, run `/ship` -first (which includes the `origin/staging` sync check — see `.agents/skills/ship/SKILL.md`) to -create one. - -## Definition of "clean" - -Both must hold: -1. The latest Greptile summary comment reports **Confidence Score: 5/5** -2. `reviewThreads` (GraphQL, see below) has **zero threads with `isResolved: false`** - -Do not stop early on "no new comments this round" alone — a thread can be open from an earlier -round. Always check both conditions freshly after every push. - -## Loop - -1. **Check current state** before doing anything, including whether the PR is still mergeable: - ```bash - gh pr view --json mergeable - gh pr view --json comments -q '[.comments[] | select(.author.login=="greptile-apps")] | last | .body' - gh api graphql -f query=' - query { repository(owner: "", name: "") { pullRequest(number: ) { - reviewThreads(first: 50) { pageInfo { hasNextPage endCursor } nodes { id isResolved path line - comments(first: 5) { nodes { id databaseId author { login } body } } } } } } }' - ``` - `[.comments[]] | last | .body`, not `... | .body | tail -1` — the latter pipes every matching - comment's full multi-line body through the pipeline and keeps only the final *line* of that - combined output (usually the "Reviews (n): Last reviewed commit..." footer), not the last - *comment*, so it silently misses the actual "Confidence Score: X/5" line. - `reviewThreads(first: 50)` is a single page — check `pageInfo.hasNextPage`. If `true`, don't - stop yet: re-run the same query with `after: ""` and keep paging until - `hasNextPage` is `false` before evaluating "clean." A PR with more than 50 threads is rare but - stopping on a partial page would silently miss unresolved ones past the cutoff. - If `mergeable` is `CONFLICTING`, fix that first (step 2). Otherwise, if Greptile is 5/5 and - every thread across all pages has `isResolved: true`, stop — report the outcome (see - "Reporting" below) and skip the rest of this list. - -2. **If the PR has a merge conflict**, merge `origin/staging`, resolve the conflicts, run the - usual pre-push checks, push, and go to step 8 to re-trigger review. - -3. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run - automatically on PR open — confirm via `gh pr checks ` (look for `Cursor Bugbot` / - `Greptile Review`) and wait for that first round before doing anything else. - -4. **If a review round has landed and it isn't clean**: for every thread where - `isResolved: false`, triage the finding on its own merits — this is the part that requires - judgment, not a mechanical loop: - - **Real bug**: fix it in the cleanest way available. Match the codebase's existing - conventions for that kind of problem before inventing a new one (e.g. an SSRF-prone - user-supplied-host fetch should use whatever `validateUrlWithDNS`/`secureFetchWithPinnedIP` - pattern the rest of the codebase already uses for that exact situation — grep for a sibling - integration solving the same problem first). Never patch around a finding with a - workaround, a broad try/catch, or a suppression comment — fix the actual cause. - - **False positive**: don't change code. Reply with the specific reason it doesn't apply - (cite the type definition, the established pattern it matches, or the doc it follows) so - the reviewer bot and a human skimming later both understand why it was left as-is. - - **Already fixed by an earlier finding in the same round**: note that and resolve without a - duplicate code change. - -5. **Reply to every thread individually** before resolving it — never resolve silently: - ```bash - gh api repos///pulls//comments//replies -f body="" - ``` - Then resolve via GraphQL (needs the thread `id` from step 1, not the comment id): - ```bash - gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' - ``` - -6. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, - the whole check-and-recover flow (stash WIP if needed, rebase, verify the rebase didn't just - cleanly replay stray commits, cherry-pick rebuild if it did or if it conflicted). A babysit - loop spanning a long session is exactly the scenario where a branch can drift, and pushing - review fixes on top of undetected drift is how an oversized PR happens even after the branch - was fixed once. Then run the repo's pre-ship checks the same way `/ship` does before - committing — not just lint/typecheck/boundary-validation, but also the conditional `/cleanup` - (if this round's fix touched UI code) and `/db-migrate` (if it touched schema/migrations) - gates from `/ship` steps 4 and 5. A review-fix round is still a code change and can trip - either gate just as easily as the original commit did. - -7. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 6's - sync check rewrote history, which includes a plain `git rebase origin/staging` that completed - with no conflicts, not only the cherry-pick rebuild path; both rewrite commits already - published to the remote, so a plain `git push` can be rejected either way — then run `/ship` - step 9's post-push verify — not just before the first push, every push in the loop: - ```bash - git fetch origin staging && git log --oneline --reverse origin/staging..HEAD - gh pr view --json commits -q '.commits[].messageHeadline' - ``` - `--reverse` makes `git log` oldest-first, matching the PR commit list's order — plain - `git log` is newest-first, so without it a positional comparison can spuriously fail on any - multi-commit branch. - These two lists must describe the same commits. A review loop runs many pushes across many - rounds; checking sync only before the push (step 6) and never after is how a bad push or a - PR whose commit history quietly went stale between rounds goes unnoticed. - -8. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR - comments** — never combine them into one comment, each bot only responds to its own mention: - ```bash - gh pr comment --body "@greptile" - gh pr comment --body "@cursor review" - ``` - -9. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using - a fallback delay of ~250–300s (Greptile/Cursor typically take 1–3 minutes) — never busy-poll - in a sleep loop. Pass the same `/loop babysit PR ` prompt on each wakeup so the loop - resumes correctly. - -10. **Stop conditions**: clean state reached (see above), or the same unresolved finding or - merge conflict survives two consecutive rounds with no new information (surface it to the - user instead of looping forever), or the user interrupts. - -## Reporting - -When the loop ends, summarize: how many rounds it took, what was actually fixed (one line each), -what was pushed back on as a false positive and why, and the final Greptile score / thread count. - -## Hard rules - -- Never post the two re-review mentions as a single combined comment. -- Never resolve a thread without replying to it first. -- Never fix a finding with a hacky workaround — if the clean fix isn't obvious, find the sibling - pattern elsewhere in the codebase solving the same class of problem and match it. -- Never silently drop a finding — every thread gets either a code fix or a reasoned reply. -- Always re-run the `/ship`-style sync check before every push in the loop, not just the first. diff --git a/.claude/commands/cleanup.md b/.claude/commands/cleanup.md deleted file mode 100644 index e4f3a2f6f2d..00000000000 --- a/.claude/commands/cleanup.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, and comments — analyzing in parallel, then applying fixes sequentially -argument-hint: "[scope] [fix=true|false]" ---- - -# Cleanup - -Arguments: -- scope: what to review (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase" -- fix: whether to apply fixes (default: true). Set to false to only propose changes. - -User arguments: $ARGUMENTS - -## Step 1 — Parallel analysis (read-only) - -First parse the user's `$ARGUMENTS` into `scope` and `fix`: extract the `fix=true|false` token wherever it appears in the string (start, middle, or end), and treat everything else — with that token removed — as `scope`. Defaults: `scope` = your current changes, `fix` = true. The `fix` value is consumed by Step 3 — it does NOT propagate to these passes, which always run `fix=false`. - -Spawn all eight passes concurrently as subagents in a **single message** (multiple Agent tool calls). Each runs its skill on the parsed `scope` with `fix=false` — analysis and proposals ONLY, no edits. Instruct each agent to return its findings as a structured list: for every proposed change, the file path, line range, a one-line description of the change, and the exact before/after so the orchestrator can apply it without re-deriving. - -Run these eight in parallel, substituting the parsed `scope` for `` in each invocation (pass the real scope text, never the literal ``): - -1. `/you-might-not-need-an-effect fix=false` -2. `/you-might-not-need-a-memo fix=false` -3. `/you-might-not-need-a-callback fix=false` -4. `/you-might-not-need-state fix=false` -5. `/react-query-best-practices fix=false` -6. `/emcn-design-review fix=false` -7. `/you-might-not-need-url-state fix=false` -8. `/you-might-not-need-a-comment fix=false` - -## Step 2 — Converge - -Collect all findings into one list, **keeping each proposal tagged with the pass that produced it** — do NOT collapse a file's proposals into a single unlabeled patch, because Step 3 applies in pass order and needs those labels. Detect overlaps where two passes touch the same region (common: a state pass and an effect pass on the same block, or a memo and callback pass on the same component). Reconcile only genuine same-region conflicts, and drop proposals a sibling pass has made moot; a reconciled change inherits the pass label of whichever of its passes comes first in the Step 3 dependency order (effects → state → memo → callback → React Query → url-state → emcn → comments), so it is applied at the earliest safe point. Non-overlapping proposals stay as-is with their own labels. The output is a per-pass list of surviving changes, not a per-file patch. - -## Step 3 — Sequential apply - -If `fix=false`, skip this step — just report the proposals from Step 2. - -Otherwise apply the surviving changes yourself (in the main context, not delegated), iterating **pass by pass** in this dependency order so earlier structural changes settle before later passes build on them: - -1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. emcn design → 8. comments - -For each pass in turn, apply all of that pass's changes, then move to the next pass. A file touched by several passes is therefore edited once per pass, in this order — not once as a merged patch. This is what makes the ordering real: a single merged-per-file patch would collapse all passes into one edit and lose it. - -Comments apply last, on purpose: that pass operates on whatever the earlier structural passes settled the code into, so it never edits lines a sibling pass is about to delete or rewrite. - -**Treat every Step 1 proposal as snapshot-relative, not authoritative.** All passes analyzed the *original* files in parallel, so a proposal's line ranges and before/after text describe the code as it was *before* any edits — once an earlier pass has run, a later pass's snippet may no longer match. So for each change, before applying: - -1. Re-read the file and locate the target by its **content** (the proposal's `old_string` snippet), not by its line number — line numbers from Step 1 are only a hint for where to look, since earlier edits shift them. -2. If the `old_string` still matches verbatim, apply it — a content-anchored edit is safe even if its line moved. -3. If it no longer matches (an earlier pass altered that region), do **not** force the stale patch. Re-derive the change from the current code by re-applying that pass's rule to the construct, or drop it if a prior pass already made it moot. Never apply a proposal against text it wasn't computed from. - -After all edits, run `bun run lint:check` (it runs `turbo run lint:check` across the repo — there is no per-file target, so run the full check). - -## Step 4 — Summary - -Output a summary across all eight passes: what each found, what was applied vs. skipped-as-redundant, and any proposals that need a human decision. - -## Boundary Audit Guidance - -- When removing route-local Zod schemas, replacing raw `fetch(` calls in hooks, or removing `as unknown as X` casts, do not introduce `// boundary-raw-fetch: ` or `// double-cast-allowed: ` annotations to silence the audit. Fix the underlying call instead — adopt a contract from `@/lib/api/contracts/**` and use `requestJson(contract, ...)` from `@/lib/api/client/request`, or refine the type so the double cast is unnecessary. -- Annotations are reserved for legitimate exceptions only: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, external-origin requests, and double casts where no narrower type is available. Each annotation requires a non-empty reason; empty reasons fail `bun run check:api-validation:strict`. diff --git a/.claude/commands/council.md b/.claude/commands/council.md deleted file mode 100644 index 8ea47fe727f..00000000000 --- a/.claude/commands/council.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Spawn parallel task agents to explore a given area of the codebase from multiple angles, then use their findings to answer the question or build a plan. Use when a task needs broad fan-out exploration across many files before acting. -argument-hint: ---- - -Based on the given area of interest, please: - -1. Dig around the codebase in terms of that given area of interest, gather general information such as keywords and architecture overview. -2. Spawn off n=10 (unless specified otherwise) task agents to dig deeper into the codebase in terms of that given area of interest, some of them should be out of the box for variance. -3. Once the task agents are done, use the information to do what the user wants. - -If user is in plan mode, use the information to create the plan. diff --git a/.claude/commands/db-migrate.md b/.claude/commands/db-migrate.md deleted file mode 100644 index 6a53b472c68..00000000000 --- a/.claude/commands/db-migrate.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -description: Author or review a Drizzle DB migration for zero-downtime safety — expand/contract phasing, backward-compatibility with the deployed app version, and writing the `-- migration-safe` acknowledgment the check:migrations lint requires. Use when adding/editing files under `packages/db/migrations/` or changing `packages/db/schema.ts`. ---- - -# DB Migrate Skill - -You make schema changes that survive a deploy without downtime. The `check:migrations` lint (`scripts/check-migrations-safety.ts`) is the deterministic gate; you are the judgment that decides whether a flagged change is actually safe and writes the annotation that satisfies it. - -## The window (why this matters) - -A deploy runs the migration, then rolls out the new app image via blue/green. The two are **not atomic and cannot be** — during cutover the old task set keeps serving against the **already-migrated** schema. So: - -> Every migration must be backward-compatible with the app version that is *already deployed*. - -If a migration drops a column the old code still reads, renames one, or adds a `NOT NULL` the old inserts don't populate, the old code throws until traffic fully shifts — the downtime we're guarding against. You can't fix this by reordering the pipeline; the only fix is discipline. - -## Expand / contract - -Split every breaking change across **two deploys**: - -1. **Expand** (this PR): additive, backward-compatible schema + code that tolerates *both* the old and new shape. -2. **Contract** (a later PR, after expand is fully deployed): remove the old thing, now that nothing reads it. - -Never put expand and contract in the same PR. If this PR both removes the code that used a column *and* drops the column, the old code is still live during cutover — split it. - -### Per-operation playbook - -| You want to | Do (deploy 1 = expand) | Do (deploy 2 = contract) | -|---|---|---| -| Add a required column | `ADD COLUMN` nullable or `DEFAULT`; code writes it | backfill, then `SET NOT NULL` | -| Rename a column/table | add the new name; code dual-writes / reads new-then-old | drop the old name | -| Drop a column/table | stop all reads/writes in code; ship it | `DROP` (annotate) | -| Change a column type | add a new column of the new type; dual-write | backfill, swap reads, drop old | -| Add FK / CHECK | `ADD CONSTRAINT ... NOT VALID` | `VALIDATE CONSTRAINT` separately | -| Index an existing table | `COMMIT;` breakpoint → `SET lock_timeout = 0` → `CREATE INDEX CONCURRENTLY IF NOT EXISTS` (see `packages/db/scripts/migrate.ts`) | — | -| Drop an index | `COMMIT;` breakpoint → `DROP INDEX CONCURRENTLY` — plain `DROP INDEX` takes ACCESS EXCLUSIVE on the table | — | -| Backfill data | batched + idempotent `UPDATE` (keyset/`WHERE`, bounded) | — | - -A `CREATE INDEX`, `ADD COLUMN`, or `ADD CONSTRAINT` against a table **created in the same migration** is always safe (no rows, no live traffic) — the lint already suppresses those. - -## Tracking the contract (don't let it rot) - -The contract half is deferred to a later deploy — and that is exactly when it gets forgotten, leaving dead columns, orphaned tables, and `NOT NULL`s that never land. Every deferred contract must become a durable, greppable TODO. - -When an expand defers a drop, leave a **`contract-pending`** marker on the legacy column/table in `packages/db/schema.ts` — that is the file you will be editing when you finally do the drop, so the reminder lives where the work happens: - -```ts -// contract-pending(after #5035 is fully deployed): drop once permission-check.ts stops reading it -workspaceId: text('workspace_id'), -``` - -Format: `contract-pending(): `. The precondition names the PR/release that removes the last reader and **must be fully deployed** before the contract ships. - -- **The TODO list is a grep** — always accurate, never drifts: `grep -rn "contract-pending" packages/db apps/sim`. Run it when starting migration work to see what is owed. -- For anything with a real owner or schedule, also open a tracking issue and put its number in the marker. -- **Close the loop in the contract PR:** the contract migration's `-- migration-safe:` annotation references the expand, and you **delete the `contract-pending` marker** in the same PR: - ```sql - -- migration-safe: contract of #5035 — workspace_id readers removed there, deployed 2026-06-10 - ALTER TABLE "permission_group" DROP COLUMN "workspace_id"; - ``` -- An expand merged **without** a marker for the drop it defers, or a contract merged **without** removing its marker, is a bug — flag it in review. - -## The judgment the lint can't do - -The lint flags risky *shapes*; it cannot know whether a given drop is *safe right now*. For each flagged statement, do the work it can't: - -1. **Is the dependency gone?** Grep the app for the table/column: search `apps/sim` and `packages` for the column name, the Drizzle field (camelCase), and the table object. If any live read/write remains, it is **not** safe — fix the code first. -2. **Did the expand already ship?** The removal of that read/write must be in a deploy that is *already out*, not this same PR. If it's in this PR, split: land the code change now, do the destructive migration in a follow-up after it deploys. -3. **Backfills:** confirm the `UPDATE`/`DELETE` is batched (bounded `WHERE`/keyset, not a single whole-table statement), idempotent (safe to replay — a failed migration re-runs unjournaled files from the top), and safe under concurrent writes from the still-live old app. - -## Workflow - -1. Edit `packages/db/schema.ts`, then `cd packages/db && bunx drizzle-kit generate` to produce the SQL. If this is an expand that defers a drop, leave a `contract-pending` marker on the legacy column (see "Tracking the contract"). If this is the contract, delete the marker it resolves. -2. Hand-edit the generated SQL where the playbook requires it: `CONCURRENTLY` + `COMMIT;` breakpoint for indexes on existing tables, `NOT VALID` for constraints, batching for backfills. -3. Run `bun run check:migrations` (base defaults to `origin/staging`). - - **Hard errors** (`add-not-null-no-default`, `rename`, `index-not-concurrent`, `constraint-not-valid`, …): rewrite into expand/contract. Do **not** try to annotate them away — the lint won't accept it. - - **Annotate tier** (`drop-table`, `drop-column`, `drop-default`, `set-not-null`, `alter-type`, `drop-index`): only after you've confirmed steps 1–3 above, add a comment on the line directly above the statement: - ```sql - -- migration-safe: `secret` read removed in v0.6.1 (#1234), shipped two deploys ago - ALTER TABLE "webhook" DROP COLUMN "secret"; - ``` - The reason must be specific and name the PR/version that removed the dependency. An empty reason fails the lint. - - **Warnings** (`data-backfill`): non-blocking, but confirm the batching/idempotency before merging. -4. Verify locally: `cd packages/db && bun run db:migrate` against a dev DB. - -## Hard rule - -Never annotate a destructive statement just to make the lint pass. The annotation is a claim that you verified the old code no longer depends on it. If you can't make that claim truthfully, the change belongs in a later deploy — tell the user to split it. diff --git a/.claude/commands/design-taste-frontend.md b/.claude/commands/design-taste-frontend.md deleted file mode 100644 index 2f58011ea16..00000000000 --- a/.claude/commands/design-taste-frontend.md +++ /dev/null @@ -1,1205 +0,0 @@ ---- -description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check. ---- - -# tasteskill: Anti-Slop Frontend Skill - -> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI. -> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits. - ---- - -## 0. BRIEF INFERENCE (Read the Room Before Anything Else) - -Before touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room. - -### 0.A Read these signals first -1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog. -2. **Vibe words** the user used - "minimalist", "calm", "Linear-style", "Awwwards", "brutalist", "premium consumer", "Apple-y", "playful", "serious B2B", "editorial", "agency-y", "glassy", "dark tech". -3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with. -4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste. -5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11). -6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference. - -### 0.B Output a one-line "Design Read" before generating -Before any code, state in one line: **"Reading this as: \ for \, with a \ language, leaning toward \."** - -Example reads: -- *"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion."* -- *"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography."* -- *"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS."* - -### 0.C If the brief is ambiguous, ask one question, do not guess -Ask exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *"Should this feel closer to Linear-clean or Awwwards-experimental?"* - -If you can confidently infer from context, **do not ask**. Just declare the design read and proceed. - -### 0.D Anti-Default Discipline -Do not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read. - ---- - -## 1. THE THREE DIALS (Core Configuration) - -After the design read, set three dials. Every layout, motion, and density decision below is gated by these. - -* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos -* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics -* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data - -**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally. - -### 1.A Dial Inference (design read → dial values) -| Signal | VARIANCE | MOTION | DENSITY | -|---|---|---|---| -| "minimalist / clean / calm / editorial / Linear-style" | 5-6 | 3-4 | 2-3 | -| "premium consumer / Apple-y / luxury / brand" | 7-8 | 5-7 | 3-4 | -| "playful / wild / Dribbble / Awwwards / experimental / agency" | 9-10 | 8-10 | 3-4 | -| "landing page / portfolio / marketing site (default)" | 7-9 | 6-8 | 3-5 | -| "trust-first / public-sector / regulated / accessibility-critical" | 3-4 | 2-3 | 4-5 | -| "redesign - preserve" | match existing | +1 | match existing | -| "redesign - overhaul" | +2 | +2 | match existing | - -### 1.B Use-Case Presets -| Use case | VARIANCE | MOTION | DENSITY | -|---|---|---|---| -| Landing (SaaS, mainstream) | 7 | 6 | 4 | -| Landing (Agency / creative) | 9 | 8 | 3 | -| Landing (Premium consumer) | 7 | 6 | 3 | -| Portfolio (Designer / studio) | 8 | 7 | 3 | -| Portfolio (Developer) | 6 | 5 | 4 | -| Editorial / Blog | 6 | 4 | 3 | -| Public-sector service | 3 | 2 | 5 | -| Redesign - preserve | match | match+1 | match | -| Redesign - overhaul | +2 | +2 | match | - -### 1.C How the Dials Drive Output -Use these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`. - ---- - -## 2. BRIEF → DESIGN SYSTEM MAP - -Once you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system. - -### 2.A When to reach for a real design system (use official packages) -| Brief reads as… | Reach for | Why | -|---|---|---| -| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done | -| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming | -| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns | -| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI | -| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS | -| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing | -| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected | -| US public-sector / trust-first | `uswds` | Same | -| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works | -| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme | -| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state | -| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds | - -**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them. - -**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app. - -### 2.B When the brief is an aesthetic, not a system -For these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material. - -| Aesthetic | Honest implementation | -|---|---| -| Glassmorphism / "frosted glass" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. | -| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. | -| Brutalism | Native CSS, monospace, raw borders. No library. | -| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. | -| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. | -| Aurora / mesh gradients | SVG or layered radial gradients. No library. | -| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. | -| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | - ---- - -## 3. DEFAULT ARCHITECTURE & CONVENTIONS - -Unless the design read picks a real design system (Section 2.A), these are the defaults: - -### 3.A Stack -* **Framework:** React or Next.js. Default to Server Components (RSC). - * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component. - * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only. -* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it. - * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin. -* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from "motion/react"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code. -* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. - -### 3.B State -* Local `useState` / `useReducer` for isolated UI. -* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context. -* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile. - -### 3.C Icons -* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`. -* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it. -* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch. -* **One family per project.** Do not mix Phosphor with Lucide in the same component tree. -* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`). - -### 3.D Emoji Policy -Discouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent. - -### 3.E Responsiveness & Layout Mechanics -* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`). -* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`. -* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar). -* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`). - -### 3.F Dependency Verification (mandatory) -Before importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists. - ---- - -## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction) - -LLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path. - -### 4.1 Typography -* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`. -* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`. -* **Sans font choice:** - * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first. - * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site. -* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`. - -* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):** - * Serif is **very discouraged as the default font for any project.** "It feels creative / premium / editorial" is NOT a reason to reach for serif. The agent's default mental model that "creative brief = serif" is the single most-tested AI tell in production rounds. - * **Serif is only acceptable when ONE of these is explicitly true:** - - The brand brief literally names a serif font, OR - - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand - * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not "boring" — they are the default for the same reason black is the default in fashion. - * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic "and `spatial` design" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move. - * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs). - * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard. - -* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping. - -### 4.2 Color Calibration -* Max 1 accent color. Saturation < 80% by default. -* **THE LILA RULE:** The "AI Purple / Blue glow" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.). -* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop. -* **One palette per project.** Do not fluctuate between warm and cool grays within the same project. -* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping. - -* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):** - * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents: - - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all "warm paper / cream / chalk / bone") - - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all "brass / clay / oxblood / ochre") - - Text: `#1a1714`, `#1a1814`, `#1b1814` (all "espresso / warm near-black") - * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible. - * **Default alternatives (rotate, do not reuse):** - - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather) - - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium) - - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige - - **Cobalt + Cream:** saturated blue against a single neutral, no brass - - **Terracotta + Slate:** warm rust against cool grey, no brass - - **Olive + Brick + Paper:** muted olive plus brick-red accent - - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.) - * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row. - * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because "this is a cookware brief" is banned. - -### 4.3 Layout Diversification -* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left-aligned content / right-aligned asset", "Asymmetric white-space", or scroll-pinned structures. -* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design. - -### 4.4 Materiality, Shadows, Cards -* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space. -* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds. -* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout. -* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. "buttons are full-pill, cards are 16px, inputs are 8px") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design. - -### 4.5 Interactive UI States -LLMs default to "static successful state only." Always implement full cycles: -* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners. -* **Empty States:** Beautifully composed; indicate how to populate. -* **Error States:** Clear, inline (forms), or contextual (toasts only for transient). -* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push. -* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke). -* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like "VIEW SELECTED WORK" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail. -* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: "Get in touch" + "Contact us" + "Let's talk" + "Start a project" + "Start something" + "Reach out" = all "contact" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for "Try free" + "Get started" + "Sign up free" (all "signup" intent) and "View work" + "See selected work" + "Browse projects" (all "portfolio" intent). One label per intent. -* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping. - -### 4.6 Data & Form Patterns -* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks. -* No placeholder-as-label. Ever. - -### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work) - -* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA. -* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error. -* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding. -* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total: - 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one - 2. Headline (max 2 lines, see above) - 3. Subtext (max 20 words, max 4 lines) - 4. CTAs (1 primary + max 1 secondary) - - **BANNED in the hero:** tiny tagline below CTAs ("Works with GitHub, GitLab, and self-hosted Git"), trust micro-strip ("Used by engineering teams at..."), pricing teaser ("Free for solo, $10/user for teams"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero. - - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max. -* **"Used by" / "Trusted by" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy. -* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design. -* **Navigation height cap: 80px max desktop, default 64-72px.** No huge "agency" nav bars that eat 15% of the viewport. -* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks. -* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile. -* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. "Selected commissions" must not look like "What we do." A landing page with 8 sections must use at least 4 different layout families. -* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating "left-image + right-text" then "left-text + right-image" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family. -* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An "eyebrow" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule: - - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total. - - If section A has an eyebrow, the next 2 sections cannot have one. - - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails. - - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed. -* **SPLIT-HEADER BAN (mandatory).** The pattern "left big headline + right small explainer paragraph" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text). -* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good. -* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No "it'll work, Tailwind handles it" assumptions. - -### 4.8 Image & Visual Asset Strategy - -Landing pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop. - -**Priority order for visual assets:** -1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster. -2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults: - * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`) - * Actual stock or brand URLs when the brief provides them - * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed -3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based "fake screenshots." Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *"This page needs real images at: \[list of placements\]. Please generate or provide them."* - -**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low. - -**Real company logos for social proof.** When the brief calls for a "Trusted by / Used by / Customers" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos: -* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands. -* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN). -* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic. -* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable). -* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it. - -**Hand-rolled illustrations:** -* SVG icons from libraries: fine (see Section 3.C). -* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when: - - The brief explicitly calls for it ("draw me an SVG logo") - - It's a single, simple geometric mark (a square, a circle, a wordmark in display type) - - You're confident in the output quality - -**Div-based fake screenshots are banned.** A "hand-built product preview" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product: -* Use a real screenshot URL if one exists -* Generate one via image tool -* Use a real component preview (an actual mini-version of the UI inside the page) -* Or skip the preview entirely and use editorial photography - -**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder. - -### 4.9 Content Density - -Landing pages live on the **first impression**, not the full read. Cut ruthlessly. - -* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job. -* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use: - - Top 3-5 highlights + "View full list" link - - Marquee / carousel for breadth - - Different page entirely if the data is the product -* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead: - - 2-column split with grouped items - - Card grid with image + label per item - - Tabs / accordion if items are categorisable - - Horizontal scroll-snap pills - - Carousel for breadth-heavy lists (testimonials, logos, capabilities) - - Marquee for "lots-of-things-that-don't-need-individual-attention" - A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout. -* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives: - - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line "why it matters" body. Cards arranged 2-col on desktop, 1-col mobile. - - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through. - - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. "Materials", "Cooking", "Warranty"), each cluster gets ONE soft divider and a cluster heading. - - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a "View full specifications" disclosure. - -* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is: - - **Grammatically broken** ("free on its past", "two plans but one is honest", "to put it on the table" out of context) - - **Has unclear referents** ("we plan to stay that way" without prior context) - - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, "elegant nothing" phrases) - - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta) - Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy. -* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either: - - Come from real data (brief, brand guidelines, public metrics) - fine - - Are explicitly labeled as mock (``, "example", "sample data") - fine - - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim. -* **One copy register per page.** Don't mix technical mono ("47 tasks · 0.6 ctx-switches/day"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it. - -### 4.10 Quotes & Testimonials - -* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review. -* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: "fits in a glance." -* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned. -* Attribution: name + role + (optionally) company. Never name only ("- Sarah"). -* Quote marks: use real typographic quotes ( " " ) or none at all. Not straight ASCII ( " ). - -### 4.11 Page Theme Lock (Light / Dark Mode Consistency) - -The page has ONE theme. Sections do not invert. - -* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll. -* The exception: if the brief explicitly calls for a "Color Block Story" or "Theme Switch on Scroll" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page. -* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken. -* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override. - ---- - -## 5. CONTEXT-AWARE PROACTIVITY - -These are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.** - -* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or "boring B2B." When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`. -* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B. -* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: "spring", stiffness: 100, damping: 20`) - no linear easing. -* **"Motion claimed, motion shown."** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups). -* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: "what does this animation communicate?" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: "it looked cool". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation. -* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees ("logos endlessly scrolling", "manifesto scrolling sideways", "kinetic word strip") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout. -* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A "card stack on scroll" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: "top top"` not `start: "top center"` or `"top 80%"`. -* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: "top top"`, pin the wrapper, scrub the inner track. - -### 5.A Sticky-Stack - Canonical Skeleton - -```tsx -"use client"; -import { useRef, useEffect } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { useReducedMotion } from "motion/react"; - -gsap.registerPlugin(ScrollTrigger); - -export function StickyStack({ cards }: { cards: React.ReactNode[] }) { - const ref = useRef(null); - const reduce = useReducedMotion(); - - useEffect(() => { - if (reduce || !ref.current) return; - const ctx = gsap.context(() => { - const cardEls = gsap.utils.toArray(".stack-card"); - cardEls.forEach((card, i) => { - if (i === cardEls.length - 1) return; - ScrollTrigger.create({ - trigger: card, - start: "top top", // pin at viewport top - endTrigger: cardEls[cardEls.length - 1], - end: "top top", - pin: true, - pinSpacing: false, - }); - gsap.to(card, { - scale: 0.92, - opacity: 0.55, - ease: "none", - scrollTrigger: { - trigger: cardEls[i + 1], - start: "top bottom", - end: "top top", - scrub: true, - }, - }); - }); - }, ref); - return () => ctx.revert(); - }, [reduce]); - - return ( -
    - {cards.map((card, i) => ( -
    - {card} -
    - ))} -
    - ); -} -``` - -Critical points: `start: "top top"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives). - -### 5.B Horizontal-Pan - Canonical Skeleton - -```tsx -"use client"; -import { useRef, useEffect } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { useReducedMotion } from "motion/react"; - -gsap.registerPlugin(ScrollTrigger); - -export function HorizontalPan({ children }: { children: React.ReactNode }) { - const wrap = useRef(null); - const track = useRef(null); - const reduce = useReducedMotion(); - - useEffect(() => { - if (reduce || !wrap.current || !track.current) return; - const ctx = gsap.context(() => { - const distance = track.current!.scrollWidth - window.innerWidth; - gsap.to(track.current, { - x: -distance, - ease: "none", - scrollTrigger: { - trigger: wrap.current, - start: "top top", // pin starts when section top hits viewport top - end: () => `+=${distance}`, // scroll distance = track width minus viewport - pin: true, - scrub: 1, - invalidateOnRefresh: true, - }, - }); - }, wrap); - return () => ctx.revert(); - }, [reduce]); - - return ( -
    -
    - {children} -
    -
    - ); -} -``` - -Critical points: `start: "top top"`, `pin: true`, `end: "+=${distance}"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically. - -### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative) - -For simple "items appear as they enter viewport" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed: - -```tsx -"use client"; -import { motion, useReducedMotion } from "motion/react"; - -export function RevealStagger({ items }: { items: string[] }) { - const reduce = useReducedMotion(); - return ( -
      - {items.map((item, i) => ( - - {item} - - ))} -
    - ); -} -``` - -Use this for: feature lists, testimonial grids, logo walls, anything that just needs "enter on scroll." Save GSAP for actual pin/scrub work. - -### 5.D Forbidden Animation Patterns - -* **`window.addEventListener("scroll", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`). -* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame. -* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead. -* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props "for safety" - it costs measurement work. -* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree. - ---- - -## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS - -### 6.A Hardware Acceleration -* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. -* Use `will-change: transform` sparingly - only on elements that will actually animate. - -### 6.B Reduced Motion (mandatory) -* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable. -* In Motion: wrap with `useReducedMotion()` and degrade to static. -* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables. -* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion. - -### 6.C Dark Mode (mandatory for any consumer-facing page) -* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction. -* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project. -* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes. -* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode. - -### 6.D Core Web Vitals Targets -* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded. -* **INP** < 200ms. Heavy work off main thread. -* **CLS** < 0.1. Reserve space for images, fonts, embeds. -* Run Lighthouse before declaring a page done. - -### 6.E DOM Cost -* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS. -* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold. - -### 6.F Z-Index Restraint -NEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file. - ---- - -## 7. DIAL DEFINITIONS (Technical Reference) - -### DESIGN_VARIANCE (Level 1-10) -* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment. -* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data. -* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`). -* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`. - -### MOTION_INTENSITY (Level 1-10) -* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway. -* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`. -* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a "prefer-not." See Section 5.D for the allowed alternatives. - -### VISUAL_DENSITY (Level 1-10) -* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean. -* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`). -* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers. - ---- - -## 8. DARK MODE PROTOCOL - -Dual-mode by default. Never assume light-only unless the brief is print-emulating editorial. - -### 8.A Token Strategy (pick one, stick to it) -* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`). -* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme="dark"]` or `@media (prefers-color-scheme: dark)`. - -### 8.B Do Not Prescribe Specific Colors Here -The brief and brand decide. This skill enforces only: -* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy. -* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark. -* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode. -* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth. - -### 8.C Default Mode -Respect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression. - -### 8.D Test in Both Modes Before Finishing -Open the page in both modes during development. Do not ship a page you've only seen in one mode. - ---- - -## 9. AI TELLS (Forbidden Patterns) - -Avoid these signatures unless the brief explicitly asks for them. - -### 9.A Visual & CSS -* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows. -* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal. -* **NO oversaturated accents.** Desaturate to blend with neutrals. -* **NO excessive gradient text** for large headers. -* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile. - -### 9.B Typography -* **AVOID Inter as default.** See Section 4.1. Override path exists. -* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale. -* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards. - -### 9.C Layout & Spacing -* **Mathematically perfect** padding and margins. No floating elements with awkward gaps. -* **NO 3-column equal feature cards.** The generic "three identical cards horizontally" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative. - -### 9.D Content & Data ("Jane Doe" Effect) -* **NO generic names.** "John Doe", "Sarah Chan", "Jack Su" → use creative, realistic, locale-appropriate names. -* **NO generic avatars.** No SVG "egg" or Lucide user icons → use believable photo placeholders or specific styling. -* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`). -* **NO startup-slop brand names.** "Acme", "Nexus", "SmartFlow", "Cloudly" → invent contextual, premium names that sound real. -* **NO filler verbs.** "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize" → concrete verbs only. - -### 9.E External Resources & Components -* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only. -* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8). -* **NO div-based fake screenshots.** Never build a fake product UI out of `
    ` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview. -* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets. -* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic. -* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined. - -### 9.F Production-Test Tells (banned outright) - -These patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to "look designed." Treat them as hard bans unless the brief explicitly calls for one. - -**Hero & top-of-page** -* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status. -* **NO "Brand · No. 01"-style sub-eyebrows.** "Marrow · No. 01 · The 6-quart" type micro-meta lines. Skip them. - -**Section numbering & micro-labels** -* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate. -* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label. -* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or "Scroll" is enough; no section-number prefix. -* **NO "Index of Work, 2018 - 2026"-style range labels** as eyebrows. Just say what the section is. - -**Separators & dots** -* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything ("foo · bar · baz · qux · quux"). If you need a separator family, prefer line breaks, hairlines, or columns. -* **NO decorative colored status dots on every list/nav/badge.** A colored dot before "ONE Q4 SLOT OPEN" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly. - -**Em-dashes & typography flourishes** -* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`). -* **NO `
    `-broken-and-italicized headlines** as a default "design move." "for thirty\*years.*" type splits. Headlines should read naturally first, get clever only when the brief demands it. -* **NO vertical rotated text** ("INDEX OF WORK, 2018 - 2026" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose. -* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page "feel designed" - banned. Use them only when they organize real content. - -**Fake product previews** -* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all. -* **NO fake version footers** ("v0.6.2-rc.1", "last sync 4s ago · main") inside fake screenshots. Adds nothing, screams AI. - -**Marketing-copy Tells** -* **NO "Quietly in use at" / "Quietly trusted by"** social-proof headers. Use natural language: "Trusted by", "Used at", "Customers include", or skip the heading entirely if the logos speak. -* **NO "From the field" / "Field notes" / "Currently on the bench" / "On our desks" / "Loose plates" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels ("Testimonials", "Latest writing", "Now working on") or skip the label. -* **NO "We respect the French ones"-style** mock-humble industry-references in body copy. Cute and AI-y. -* **NO weather / locale strips** ("LIS 14:23 · 18°C") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio. -* **NO micro-meta-sentences under eyebrows.** Sentences like *"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose."* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough. -* **NO generic step labels.** "Stage 1 / Stage 2 / Stage 3", "Step 1 / Step 2 / Step 3", "Phase 01 / Phase 02 / Phase 03", "Pass One / Pass Two / Pass Three". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly ("Install", "Configure", "Ship") not "Stage 1: Install". - -**Pills, labels and version stamps** -* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image). -* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption ("The 6-quart, in Sage."). -* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages. -* **NO "Reservation 412 of 800"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data. - -**Decoration text strips** -* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site). -* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph. - -**Lists, dividers and scoring** -* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components. -* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show "X out of Y" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page. - -**Locale, time, scroll cues** -* **Locale / city-name / time / weather strips are banned for 99% of briefs.** "Lisbon, working with founders" in the hero, "1200-690 Lisbon, Portugal" in the footer, "Lisbon 14:23 · 18°C" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not. -* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label. -* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section. - -### 9.G EM-DASH BAN (the single most-violated Tell) - -**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no "limited use" allowance, no "natural language frequency" allowance, no "in body copy is fine" allowance. None. - -* **Banned in headlines.** Use a period or a comma. -* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines. -* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon. -* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name. -* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen. - -The ONLY permitted dash characters on the page are: -* Regular hyphen `-` (for compound words, ranges, line dividers in markup) -* Minus sign in math (`-5°C`) - -If your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten. - -This rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as "use sparingly." The phrasing here is binary: zero em-dashes. - ---- - -## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know) - -This is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.** - -### Hero Paradigms -* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space. -* **Editorial Manifesto Hero** - Large type, no asset, almost-poster. -* **Video / Media Mask Hero** - Type cut out as mask over video background. -* **Kinetic-Type Hero** - Animated typography as the primary visual. -* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain. -* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind. - -### Navigation & Menus -* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover. -* **Magnetic Button** - Pulls toward cursor. -* **Gooey Menu** - Sub-items detach like viscous liquid. -* **Dynamic Island** - Morphing pill for status / alerts. -* **Contextual Radial Menu** - Circular menu expanding at click point. -* **Floating Speed Dial** - FAB springing into curved secondary actions. -* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content. - -### Layout & Grids -* **Bento Grid** - Asymmetric tile grouping (Apple Control Center). -* **Masonry Layout** - Staggered grid, no fixed row height. -* **Chroma Grid** - Borders / tiles with subtle animating gradients. -* **Split-Screen Scroll** - Two halves sliding in opposite directions. -* **Sticky-Stack Sections** - Sections that pin and stack on scroll. - -### Cards & Containers -* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates. -* **Spotlight Border Card** - Borders illuminate under cursor. -* **Glassmorphism Panel** - Frosted glass with inner refraction. -* **Holographic Foil Card** - Iridescent rainbow shift on hover. -* **Tinder Swipe Stack** - Physical card stack, swipe-away. -* **Morphing Modal** - Button expands into its own dialog. - -### Scroll Animations -* **Sticky Scroll Stack** - Cards stick and physically stack. -* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan. -* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar. -* **Zoom Parallax** - Central background image zooming on scroll. -* **Scroll Progress Path** - SVG line drawing along scroll. -* **Liquid Swipe Transition** - Page transition like viscous liquid. - -### Galleries & Media -* **Dome Gallery** - 3D panoramic gallery. -* **Coverflow Carousel** - 3D carousel with angled edges. -* **Drag-to-Pan Grid** - Boundless draggable canvas. -* **Accordion Image Slider** - Narrow strips expanding on hover. -* **Hover Image Trail** - Mouse leaves popping image trail. -* **Glitch Effect Image** - RGB-channel shift on hover. - -### Typography & Text -* **Kinetic Marquee** - Endless text bands reversing on scroll. -* **Text Mask Reveal** - Massive type as transparent window to video. -* **Text Scramble Effect** - Matrix-style decoding on load / hover. -* **Circular Text Path** - Text curving along spinning circle. -* **Gradient Stroke Animation** - Outlined text with running gradient. -* **Kinetic Typography Grid** - Letters dodging the cursor. - -### Micro-Interactions & Effects -* **Particle Explosion Button** - CTA shatters into particles on success. -* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets. -* **Skeleton Shimmer** - Shifting light reflection across placeholders. -* **Directional Hover-Aware Button** - Fill enters from cursor's exact side. -* **Ripple Click Effect** - Wave from click coordinates. -* **Animated SVG Line Drawing** - Vectors drawing themselves in real time. -* **Mesh Gradient Background** - Organic lava-lamp blobs. -* **Lens Blur Depth** - Background UI blurred to focus foreground action. - -### Animation Library Choice -* **Motion (`motion/react`)** - default for UI / Bento / state-change motion. -* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup. -* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule. -* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames. - ---- - -## 11. REDESIGN PROTOCOL - -This skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output. - -### 11.A Detect the Mode (first action) -* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1. -* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually. -* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA. - -If ambiguous, ask **once**: *"Should this redesign preserve the existing brand, or are we starting visually from scratch?"* - -### 11.B Audit Before Touching -Document the current state before proposing changes: -* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii. -* **Information architecture** - page tree, primary nav, key conversion paths. -* **Content blocks** - what exists, what's doing work, what's filler. -* **Patterns to preserve** - signature interactions, recognisable hero, copy voice. -* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps. -* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline. -* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.** - -### 11.C Preservation Rules -* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory. -* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override. -* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite. -* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast. -* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on. - -### 11.D Modernisation Levers (priority order) -Apply in order - stop when the brief is satisfied: -1. **Typography refresh** - biggest visual lift per unit of risk. -2. **Spacing & rhythm** - increase section padding, fix vertical rhythm. -3. **Color recalibration** - desaturate, unify neutrals, keep brand accent. -4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components. -5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary. -6. **Full block replacement** - only when the existing block is unsalvageable. - -### 11.E Decision Tree: Targeted Evolution vs Full Redesign -* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk. -* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation. -* Brand itself is changing → **greenfield**. - -### 11.F What Never Changes Silently -Never modify without explicit user approval: -* URL structure / route slugs. -* Primary nav labels. -* Form field names or order (breaks analytics + autofill). -* Brand logo or wordmark. -* Existing legal / consent / cookie copy. - ---- - -## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively) - -The Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches. - -**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema. - -### 12.A File Location -``` -skills/taste-skill/blocks/ - hero/ - asymmetric-split.md - editorial-manifesto.md - kinetic-type.md - ... - feature/ - bento-grid.md - sticky-scroll-stack.md - zig-zag.md - ... - social-proof/ - pricing/ - cta/ - footer/ - navigation/ - portfolio/ - transition/ -``` - -### 12.B Required Frontmatter -```yaml ---- -name: asymmetric-split-hero -category: hero -dial_compatibility: - variance: [6, 10] - motion: [3, 10] - density: [2, 5] -when_to_use: "Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer." -not_for: "Editorial / manifesto launches where the message IS the design." -stack: ["react", "next", "tailwind", "motion"] ---- -``` - -### 12.C Required Body Sections -1. **Visual sketch** - short ASCII or description of the layout. -2. **Props API** - the component's interface. -3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion). -4. **Mobile fallback** - explicit collapse rules for `< 768px`. -5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit. -6. **Dark-mode notes** - token strategy specific to this block. -7. **Anti-patterns** - common ways this block goes wrong. -8. **References** - links to real examples in production. - -### 12.D Block-Library Discipline -* One block per file. No multi-block files. -* Every block must work standalone (drop it into a page, it renders). -* Every block must pass the Pre-Flight Check (Section 14). -* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`). - ---- - -## 13. OUT OF SCOPE - -This skill is NOT for: -* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A). -* Data tables (use TanStack Table or AG Grid). -* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better). -* Code editors (use Monaco / CodeMirror with their official skinning). -* Native mobile (use Apple HIG / Material directly). -* Realtime collab UIs (presence, cursors, OT-aware - different problem class). - -If the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply. - ---- - -## 14. FINAL PRE-FLIGHT CHECK - -Run this matrix before outputting code. This is the last filter. - -**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.** - -- [ ] **Brief inference** declared (Section 0.B one-liner)? -- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline? -- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly? -- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)? -- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.) -- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)? -- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)? -- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)? -- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)? -- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop? -- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background? -- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project? -- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project? -- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve? -- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image? -- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport? -- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero? -- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1. -- [ ] **Split-Header Ban**: no "left big headline + right small explainer paragraph" pattern as a section header (vertical stack instead)? -- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout? -- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent ("Get in touch" + "Let's talk" both on page = Fail)? -- [ ] **Logo wall = logo only**: no industry / category labels printed below logos? -- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards? -- [ ] **"Used by / Trusted by" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks? -- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases ("free on its past" type) shipped? -- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show? -- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page? -- [ ] **Navigation on ONE line** at desktop, height ≤ 80px? -- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)? -- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)? -- [ ] **Long lists use the right UI component** (not default `
      ` with `divide-y` for > 5 items - see Section 4.9 alternatives)? -- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism? -- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)? -- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)? -- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages? -- [ ] **No micro-meta-sentences** under eyebrows ("Each of these is a feature we ship today...")? -- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)? -- [ ] **No floating top-right sub-text** in section headings? -- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals? -- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused? -- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)? -- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch? -- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)? -- [ ] **No decorative dots** (zero by default, only for real semantic state)? -- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables? -- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default? -- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)? -- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed? -- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: "top top"`, `pin: true`, correct scrub)? -- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only? -- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`? -- [ ] **Dark mode** tokens defined and tested in both modes? -- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts? -- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`? -- [ ] **`useEffect` animations** have strict cleanup functions? -- [ ] **Empty / loading / error** states provided? -- [ ] **Cards omitted** in favor of spacing where possible? -- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths? -- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized? -- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, "Quietly in use at")? -- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)? -- [ ] **One design system** per project (no Material + shadcn mixed)? - -If a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering. - ---- - -# APPENDICES - Real Source-Backed Reference Material - -The sections below are vendored reference content. They give the agent real install commands, real canonical doc links, and real working starter snippets for each design system named in Section 2. Use them to ground decisions in production reality, not training-data fiction. - -## Appendix A - Install Commands per Design System - -```bash -# Material Web (Material 3) -npm install @material/web - -# Fluent UI React (v9) -npm install @fluentui/react-components - -# Fluent UI Web Components (framework-free) -npm install @fluentui/web-components @fluentui/tokens - -# IBM Carbon -npm install @carbon/react @carbon/styles - -# Radix Themes -npm install @radix-ui/themes - -# shadcn/ui (open code, owned components) -npx shadcn@latest init -npx shadcn@latest add button card badge separator input - -# Primer CSS (GitHub product/devtool UI) -npm install --save @primer/css - -# Primer Brand (GitHub marketing UI) -npm install @primer/react-brand - -# GOV.UK Frontend -npm install govuk-frontend - -# USWDS (US Web Design System) -npm install uswds - -# Atlassian Design System (Atlaskit) -yarn add @atlaskit/css-reset @atlaskit/tokens @atlaskit/button @atlaskit/badge @atlaskit/section-message @atlaskit/card - -# Bootstrap 5.3 -npm install bootstrap - -# Shopify Polaris Web Components (Shopify apps only) -# Add this to your app HTML head: -# -# -``` - -## Appendix B - Canonical Sources (read these before reinventing) - -### Material Web -- https://github.com/material-components/material-web -- https://material-web.dev/theming/material-theming/ -- https://m3.material.io/develop/web - -### Fluent UI -- https://fluent2.microsoft.design/get-started/develop -- https://fluent2.microsoft.design/components/web/react/ -- https://github.com/microsoft/fluentui -- https://learn.microsoft.com/en-us/fluent-ui/web-components/ - -### Carbon -- https://carbondesignsystem.com/ -- https://github.com/carbon-design-system/carbon -- https://carbondesignsystem.com/developing/react-tutorial/overview/ -- https://carbondesignsystem.com/developing/web-components-tutorial/overview/ - -### Shopify Polaris -- https://shopify.dev/docs/api/app-home/web-components -- https://github.com/Shopify/polaris-react -- https://polaris-react.shopify.com/components - -### Atlassian -- https://atlassian.design/get-started/develop -- https://atlassian.design/components/button/examples -- https://atlaskit.atlassian.com/packages/design-system/button/example/disabled -- https://atlassian.design/tokens/design-tokens - -### Primer -- https://primer.style/ -- https://github.com/primer/css -- https://github.com/primer/brand - -### GOV.UK -- https://design-system.service.gov.uk/components/button/ -- https://design-system.service.gov.uk/styles/layout/ -- https://github.com/alphagov/govuk-frontend - -### USWDS -- https://designsystem.digital.gov/documentation/developers/ -- https://designsystem.digital.gov/components/button/ -- https://designsystem.digital.gov/components/card/ -- https://github.com/uswds/uswds - -### Bootstrap -- https://getbootstrap.com/docs/5.3/layout/grid/ -- https://getbootstrap.com/docs/5.3/components/card/ - -### Tailwind -- https://tailwindcss.com/docs/dark-mode -- https://tailwindcss.com/blog/tailwindcss-v4 - -### Radix -- https://www.radix-ui.com/themes/docs/components/theme -- https://www.radix-ui.com/themes/docs/components/card -- https://github.com/radix-ui/themes - -### shadcn/ui -- https://ui.shadcn.com/docs -- https://ui.shadcn.com/docs/components/card -- https://github.com/shadcn-ui/ui - -### Native CSS / W3C standards -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backdrop-filter -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion -- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout -- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations -- https://drafts.csswg.org/scroll-animations-1/ - -### Apple Liquid Glass (Apple platforms only) -- https://developer.apple.com/design/human-interface-guidelines/materials -- https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass -- https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass -- https://developer.apple.com/documentation/SwiftUI/Material - ---- - -## Appendix C - Apple Liquid Glass: Honest Web Approximation - -Do **not** treat random CSS snippets as official Apple Liquid Glass. - -### What is official -Apple documents Liquid Glass inside Apple's Human Interface Guidelines and Developer Documentation for **Apple platforms**. It is a dynamic material used across Apple platform UI. Apple's native implementation belongs to Apple platform APIs and system components, **not a public web CSS package**. - -Relevant official docs: -- Apple Human Interface Guidelines → Materials -- Apple Developer Documentation → Liquid Glass -- Apple Developer Documentation → Adopting Liquid Glass -- SwiftUI → Material - -### What is NOT official -There is no `liquid-glass.css` from Apple for normal websites. - -A web approximation can use: -- `backdrop-filter` -- transparent backgrounds -- layered borders -- highlight overlays -- gradients -- motion -- strong contrast fallbacks - -But that is **web glassmorphism / frosted-glass approximation**, not official Apple Liquid Glass. Label it as such in comments. - -### Safer web approximation skeleton - -```css -.liquid-glass-web-approx { - position: relative; - isolation: isolate; - overflow: hidden; - border-radius: 999px; - border: 1px solid rgb(255 255 255 / .32); - background: - linear-gradient(135deg, rgb(255 255 255 / .30), rgb(255 255 255 / .08)), - rgb(255 255 255 / .12); - backdrop-filter: blur(24px) saturate(180%) contrast(1.05); - -webkit-backdrop-filter: blur(24px) saturate(180%) contrast(1.05); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / .48), - inset 0 -1px 0 rgb(255 255 255 / .12), - 0 18px 60px rgb(0 0 0 / .18); -} - -.liquid-glass-web-approx::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - background: - radial-gradient(circle at 20% 0%, rgb(255 255 255 / .55), transparent 34%), - linear-gradient(90deg, rgb(255 255 255 / .18), transparent 42%, rgb(255 255 255 / .14)); - pointer-events: none; -} - -.liquid-glass-web-approx::after { - content: ""; - position: absolute; - inset: 1px; - border-radius: inherit; - border: 1px solid rgb(255 255 255 / .14); - pointer-events: none; -} - -@media (prefers-color-scheme: dark) { - .liquid-glass-web-approx { - border-color: rgb(255 255 255 / .18); - background: - linear-gradient(135deg, rgb(255 255 255 / .16), rgb(255 255 255 / .04)), - rgb(15 23 42 / .42); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / .22), - 0 18px 60px rgb(0 0 0 / .42); - } -} - -@media (prefers-reduced-transparency: reduce) { - .liquid-glass-web-approx { - background: rgb(255 255 255 / .96); - backdrop-filter: none; - -webkit-backdrop-filter: none; - } -} -``` - -**Important:** `prefers-reduced-transparency` has uneven browser support; test it. Always provide enough contrast even without blur. - ---- - -**End of appendices.** Install commands above are reality anchors. The Apple Liquid Glass skeleton is a labeled approximation, not an Apple-issued package. For canonical docs per design system, consult the system's official docs (links in Section 2 plus Appendix B). diff --git a/.claude/commands/emcn-design-review.md b/.claude/commands/emcn-design-review.md deleted file mode 100644 index 1a5c562facd..00000000000 --- a/.claude/commands/emcn-design-review.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -description: Review UI code for alignment with the emcn design system — components, tokens, patterns, and conventions -argument-hint: "[scope] [fix=true|false]" ---- - -# EMCN Design Review - -Arguments: -- scope: what to review (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase" -- fix: whether to apply fixes (default: true). Set to false to only propose changes. - -User arguments: $ARGUMENTS - -## Context - -This codebase uses **emcn**, a custom component library built on Radix UI primitives with CVA variants and CSS variable design tokens. All UI must use emcn components and tokens. - -## Steps - -1. Read the emcn public barrel at `packages/emcn/src/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `packages/emcn/src/icons/index.ts` -2. Read `apps/sim/app/_styles/globals.css` for CSS variable tokens -3. Analyze the specified scope against every rule below -4. If fix=true, apply the fixes. If fix=false, propose the fixes without applying. - ---- - -## Imports - -- Import from `@/components/emcn` barrel, never subpaths -- Icons from `@sim/emcn/icons` -- Use `cn` from `@/lib/core/utils/cn` for conditional classes - -## Design Tokens - -Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantics (`text-muted-foreground`) or hardcoded colors (`text-gray-500`, `#333`). - -**Text**: `--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-muted`, `--text-body` (canonical value text), `--text-icon`, `--text-placeholder`, `--text-subtle`, `--text-inverse`, `--text-error` -**Surfaces**: `--bg`, `--surface-1` through `--surface-7`, `--surface-hover`, `--surface-active` -**Borders**: `--border`, `--border-1`, `--border-muted` -**Brand/accent**: `--brand-secondary`, `--brand-accent` -**Z-Index**: `--z-dropdown` (100), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-toast` (500) -**Shadows**: `shadow-subtle`, `shadow-medium`, `shadow-overlay`, `shadow-card` -**Badges**: `--badge-*` semantic families (success/error/gray/blue/purple/orange/amber/teal/cyan/pink, each with `-bg`/`-text`) - -## Buttons - -Intent-to-variant mapping (read the actual `buttonVariants` in `packages/emcn/src/components/button/button.tsx` for the full variant set — it exposes more than listed here): - -| Action | Variant | -|--------|---------| -| Toolbar, icon-only | `ghost` | -| Create, save, submit | `primary` | -| Cancel, close | `default` | -| Delete, remove | `destructive` | -| Selected state | `active` | -| Toggle | `outline` | - -## Delete/Remove Confirmations - -`ChipModal` `size='sm'`, title "Delete/Remove {ItemType}", destructive confirm button, plain Cancel (follow the chip footer layout in `.claude/rules/emcn-components.md`). Use `text-[var(--text-error)]` for irreversible warnings. - -## Toast - -`toast.success()`, `toast.error()`, `toast()` from `@/components/emcn`. Never custom notification UI. - -## Badges - -`red`=error/failed, `gray-secondary`=metadata/roles, `type`=type annotations, `green`=success/active, `gray`=neutral, `amber`=processing, `orange`=paused, `blue`=info. Use `dot` prop for status indicators. - -## Icons - -Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets. - -## Anti-patterns to flag - -- Raw `
    - {(allConnected || enrollment.status === 'completed') && ( -
    - - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - -
    - )} +
    + + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
) diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index 8207e2c3acf..4e473134b6b 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -50,8 +50,16 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec const port = parseLoopbackPort(typeof params.port === 'string' ? params.port : '') const workspaceId = isValidOpaqueId(params.workspaceId) ? params.workspaceId : undefined const credentialId = isValidOpaqueId(params.credentialId) ? params.credentialId : undefined + const draftId = isValidOpaqueId(params.draftId) ? params.draftId : undefined const expectedUserId = isValidOpaqueId(params.user) ? params.user : undefined - if (!isValidOAuthProviderId(providerId) || !isValidHandoffState(state) || port === null) { + const hasInvalidDraftId = params.draftId !== undefined && draftId === undefined + if ( + !isValidOAuthProviderId(providerId) || + !isValidHandoffState(state) || + port === null || + hasInvalidDraftId || + (workspaceId !== undefined && draftId !== undefined) + ) { return } @@ -65,6 +73,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, }) )}` @@ -86,6 +95,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec returnTo={buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, })} /> @@ -113,6 +123,9 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec } return ( - + ) } diff --git a/apps/sim/app/desktop/connect/validation.test.ts b/apps/sim/app/desktop/connect/validation.test.ts index 161742ce325..13bdbda1bc9 100644 --- a/apps/sim/app/desktop/connect/validation.test.ts +++ b/apps/sim/app/desktop/connect/validation.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { buildConnectCompletePath, buildConnectLoopbackUrl, @@ -36,19 +37,23 @@ describe('sanitizeOAuthErrorSlug', () => { describe('URL builders', () => { it('buildDesktopConnectPath round-trips provider, state, and port', () => { - const path = buildDesktopConnectPath('google-email', STATE, 49152) + const path = buildDesktopConnectPath('google-email', STATE, 49152, { + draftId: 'draft-1', + }) const url = new URL(path, 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect') expect(url.searchParams.get('provider')).toBe('google-email') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get('draftId')).toBe('draft-1') }) - it('buildConnectCompletePath carries state and port', () => { - const url = new URL(buildConnectCompletePath(STATE, 49152), 'https://sim.ai') + it('buildConnectCompletePath carries state, port, and the exact credential draft', () => { + const url = new URL(buildConnectCompletePath(STATE, 49152, 'draft-1'), 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect/complete') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM)).toBe('draft-1') }) it('buildConnectLoopbackUrl targets the 127.0.0.1 connect callback, error optional', () => { diff --git a/apps/sim/app/desktop/connect/validation.ts b/apps/sim/app/desktop/connect/validation.ts index e45d8a6d2f3..4a70467707e 100644 --- a/apps/sim/app/desktop/connect/validation.ts +++ b/apps/sim/app/desktop/connect/validation.ts @@ -1,3 +1,5 @@ +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' + /** * OAuth providerIds are kebab-case service slugs (e.g. "google-email"). The * value is only used to start a better-auth oauth2.link flow, which validates @@ -36,6 +38,7 @@ export function isValidOpaqueId(value: unknown): value is string { export interface ConnectScope { workspaceId?: string credentialId?: string + draftId?: string /** The account the desktop app is signed in as; the flow is pinned to it. */ user?: string } @@ -53,6 +56,7 @@ export function buildDesktopConnectPath( const params = new URLSearchParams({ provider: providerId, state, port: String(port) }) if (scope.workspaceId) params.set('workspaceId', scope.workspaceId) if (scope.credentialId) params.set('credentialId', scope.credentialId) + if (scope.draftId) params.set('draftId', scope.draftId) if (scope.user) params.set('user', scope.user) return `/desktop/connect?${params.toString()}` } @@ -61,8 +65,9 @@ export function buildDesktopConnectPath( * The same-origin path better-auth redirects the browser to after the OAuth * callback — the complete page then bounces to the desktop app's loopback. */ -export function buildConnectCompletePath(state: string, port: number): string { +export function buildConnectCompletePath(state: string, port: number, draftId?: string): string { const params = new URLSearchParams({ state, port: String(port) }) + if (draftId) params.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId) return `/desktop/connect/complete?${params.toString()}` } diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..72606f82511 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,39 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index a5bd8cdf51a..c3624387ae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -256,6 +256,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { setSubmitError(null) try { let connectorType: string | undefined + let draftId: string | undefined if (isConnect) { const trimmed = displayName.trim() @@ -264,12 +265,13 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { return } - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmed, description: description.trim() || undefined, }) + draftId = draft.draftId const preCount = credentials.filter( (c) => c.type === 'oauth' && c.providerId === providerId @@ -279,6 +281,15 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { displayName: trimmed, providerId, preCount, + baselineCredentials: credentials + .filter( + (credential) => credential.type === 'oauth' && credential.providerId === providerId + ) + .map((credential) => ({ + id: credential.id, + accountId: credential.accountId, + updatedAt: credential.updatedAt, + })), workspaceId, requestedAt: Date.now(), } @@ -319,6 +330,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { await connectOAuthService.mutateAsync({ providerId, callbackURL: callbackURL.toString(), + draftId, }) handleClose() } catch (err: unknown) { diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts new file mode 100644 index 00000000000..227c17308fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts @@ -0,0 +1,41 @@ +/** + * The row ids a drag carries, written to and read from `dataTransfer` as JSON under a + * private MIME type. + * + * The payload has to live on the event rather than only in component state: a drag survives + * the source row unmounting — spring-loading navigates away mid-drag — and it can be released + * over a different mount of the same page. + * + * Each surface passes its own MIME so a drag from one list is never mistaken for a drag from + * another, and so an unrelated OS drag is ignored outright. + */ + +/** Writes `rowIds` under `mime`, plus a plain-text fallback for drops outside the app. */ +export function writeRowDragPayload( + dataTransfer: DataTransfer, + mime: string, + rowIds: string[] +): void { + dataTransfer.setData(mime, JSON.stringify(rowIds)) + dataTransfer.setData('text/plain', rowIds.join(',')) +} + +/** + * Reads the row ids back, returning `null` when the payload is absent (a foreign drag) or + * malformed (another writer on the same MIME) rather than throwing mid-drop. Callers fall back + * to their in-memory source for drags that never round-tripped through `dataTransfer`. + */ +export function readRowDragPayload(dataTransfer: DataTransfer, mime: string): string[] | null { + const raw = dataTransfer.getData(mime) + if (!raw) return null + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + const rowIds = parsed.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + return rowIds.length > 0 ? rowIds : null + } catch { + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 7a2b3d88f50..1ff57a3becb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -91,7 +91,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const trailing = options.trailing ?? NO_TRAILING_CRUMBS const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, + { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, ] breadcrumbs.forEach((folder, index) => { @@ -99,6 +99,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ label: folder.name, + folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: isOpenFolder && options.currentFolderActions?.length diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 614786a1ed3..41b8682aef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -35,9 +35,8 @@ interface FolderContextMenuProps { * Row context menu for a folder, shared by the resource lists built on the generic folder * engine — Knowledge and Tables — so a folder offers the same actions on both. * - * Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a - * folder there routes through `FileRowContextMenu` alongside the file rows it is selected - * with. Converging the two is follow-up work. + * Files is deliberately not a consumer: a folder there routes through `FileRowContextMenu` + * alongside the file rows it is selected with. Converging the two is follow-up work. * * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts index f4561a3adef..82042473d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts @@ -26,3 +26,23 @@ export function parseFolderedRowId(rowId: string): ParsedFolderedRowId { } return { kind: 'resource', id: rowId } } + +/** + * Splits a selection of foldered row ids into the two id lists every bulk operation takes. + * + * A foldered list holds folder rows and resource rows in one selection (see + * {@link folderRowId}), so every consumer needs this same split before it can call an API. + */ +export function splitFolderedRowIds(rowIds: Iterable): { + folderIds: string[] + resourceIds: string[] +} { + const folderIds: string[] = [] + const resourceIds: string[] = [] + for (const rowId of rowIds) { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') folderIds.push(parsed.id) + else resourceIds.push(parsed.id) + } + return { folderIds, resourceIds } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts index 994dfaf5e49..68e6b3b218f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components import { folderRowId, parseFolderedRowId, + splitFolderedRowIds, } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, } from '@/app/workspace/[workspaceId]/components/folders/move-options' @@ -331,3 +333,114 @@ describe('folderAncestorChain', () => { expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a']) }) }) + +describe('splitFolderedRowIds', () => { + it('separates folder rows from resource rows', () => { + const { folderIds, resourceIds } = splitFolderedRowIds([ + folderRowId('f-1'), + 'res-1', + folderRowId('f-2'), + 'res-2', + ]) + + expect(folderIds).toEqual(['f-1', 'f-2']) + expect(resourceIds).toEqual(['res-1', 'res-2']) + }) + + it('returns empty lists for an empty selection', () => { + expect(splitFolderedRowIds([])).toEqual({ folderIds: [], resourceIds: [] }) + }) + + it('accepts a Set, which is how a selection is actually held', () => { + const { folderIds, resourceIds } = splitFolderedRowIds(new Set([folderRowId('f-1'), 'res-1'])) + expect(folderIds).toEqual(['f-1']) + expect(resourceIds).toEqual(['res-1']) + }) +}) + +describe('buildMoveOptionsExcludingSubtrees', () => { + /** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */ + const folders = [makeFolder('a'), makeFolder('a1', 'a'), makeFolder('a1x', 'a1'), makeFolder('b')] + const descendantsByFolderId = buildDescendantIndex(folders) + const valuesOf = (nodes: ReturnType): string[] => + nodes.flatMap((node) => [node.value, ...valuesOf(node.children)]) + + it('offers every folder when nothing is excluded', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: [], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a', 'a1', 'a1x', 'b']) + }) + + it('excludes a moving folder and its whole subtree, never offering a cycle', () => { + // The invariant this helper exists to hold: a folder can never be filed into itself or + // anything beneath it, at any depth. + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'b']) + }) + + it('excludes the union of several selected subtrees', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a1', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a']) + }) + + it('always keeps the workspace root as a destination', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE]) + }) +}) + +describe('folderBreadcrumbItems drag destinations', () => { + const chain = [makeFolder('a'), makeFolder('a1', 'a')] + + it('names the folder each crumb points at, so the header can accept a drop on it', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + }) + + expect(items.map((item) => item.folderId)).toEqual([null, 'a', 'a1']) + }) + + it('leaves a trailing crumb without a folder id, so it stays inert', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + trailing: [{ label: 'report.md', terminal: true }], + }) + + expect(items.at(-1)).toMatchObject({ label: 'report.md' }) + expect(items.at(-1)?.folderId).toBeUndefined() + }) + + it('gives the root crumb null rather than omitting it — the root is a real destination', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: [], + onNavigate: vi.fn(), + }) + + expect(items).toHaveLength(1) + expect(items[0]).toHaveProperty('folderId', null) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index aee06919742..20c6a275243 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,3 +1,4 @@ +export { readRowDragPayload, writeRowDragPayload } from './drag-payload' export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' @@ -5,16 +6,17 @@ export { nextUntitledFolderName } from './folder-naming' export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' -export { folderRowId, parseFolderedRowId } from './folder-row-id' +export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id' export type { FolderedHeaderResourceType, FolderedResourceHeaderMeta, } from './foldered-resources' export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources' -export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' +export type { BuildMoveOptionsParams, MoveOptionFolder, MoveOptionNode } from './move-options' export { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, renderMoveOption, @@ -23,9 +25,20 @@ export { export type { SortableResource } from './resource-sort' export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' +export { useDragTeardown } from './use-drag-teardown' export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors' export { useFolderAncestors } from './use-folder-ancestors' export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' export { useFolderNavigation } from './use-folder-navigation' -export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' +export type { FolderedRowMove, UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' export { useFolderRowDragDrop } from './use-folder-row-drag-drop' +export type { RowDragGhost } from './use-row-drag-ghost' +export { useRowDragGhost } from './use-row-drag-ghost' +export type { + SpringLoadedFolder, + SpringOpenOptions, + UseSpringLoadedFolderOptions, +} from './use-spring-loaded-folder' +export { SPRING_LOAD_DELAY_MS, useSpringLoadedFolder } from './use-spring-loaded-folder' +export type { SpringNavigation, UseSpringNavigationOptions } from './use-spring-navigation' +export { useSpringNavigation } from './use-spring-navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index 66f0b2e455a..865c50bca74 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -7,7 +7,6 @@ import { DropdownMenuSubTrigger, } from '@sim/emcn' import { Folder } from '@sim/emcn/icons' -import type { WorkflowFolder } from '@/stores/folders/types' export interface MoveOptionNode { value: string @@ -26,8 +25,16 @@ export function parseMoveOptionValue(optionValue: string): string | null { return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue } +/** The folder fields the move-option builders actually read, so any folder tree can use them. */ +export interface MoveOptionFolder { + id: string + name: string + parentId: string | null + sortOrder: number +} + export interface BuildMoveOptionsParams { - folders: WorkflowFolder[] + folders: readonly MoveOptionFolder[] rootLabel: string /** * Folder ids that must not appear as destinations — the folder being moved and every @@ -53,7 +60,7 @@ export function buildMoveOptions({ rootLabel, excludedFolderIds, }: BuildMoveOptionsParams): MoveOptionNode[] { - const childrenByParent = new Map() + const childrenByParent = new Map() for (const folder of folders) { if (excludedFolderIds?.has(folder.id)) continue const parentId = folder.parentId ?? null @@ -80,7 +87,9 @@ export function buildMoveOptions({ * candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits * between constraint checks. */ -export function buildDescendantIndex(folders: WorkflowFolder[]): Map> { +export function buildDescendantIndex( + folders: readonly { id: string; parentId: string | null }[] +): Map> { const childrenByParent = new Map() for (const folder of folders) { if (!folder.parentId) continue @@ -167,3 +176,38 @@ export function renderMoveOptions( ) } + +/** + * Move destinations for a selection, with every selected folder and its subtree excluded — a + * folder cannot be filed into itself or anything beneath it. + * + * Shared because that exclusion is a correctness invariant, not a preference: hand-copying it + * per surface is how one list eventually offers a cyclic destination. Covers the single-folder + * case too — pass a one-element array. + * + * Expanding each selection to its descendants is deliberately belt-and-braces: {@link + * buildMoveOptions} descends from the root, so an excluded folder already takes its subtree out + * of the walk. The explicit expansion keeps the invariant true of the exclusion set itself, so + * it survives that walk ever being replaced by a flat render. + */ +export function buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel, + excludeFolderIds, + descendantsByFolderId, +}: { + folders: readonly MoveOptionFolder[] + rootLabel: string + excludeFolderIds: readonly string[] + descendantsByFolderId: Map> +}): MoveOptionNode[] { + if (excludeFolderIds.length === 0) return buildMoveOptions({ folders, rootLabel }) + + const excludedFolderIds = new Set(excludeFolderIds) + for (const folderId of excludeFolderIds) { + for (const descendantId of descendantsByFolderId.get(folderId) ?? []) { + excludedFolderIds.add(descendantId) + } + } + return buildMoveOptions({ folders, rootLabel, excludedFolderIds }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx new file mode 100644 index 00000000000..d107519d7d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' + +const mountedRoots: Root[] = [] + +function renderDragTeardown(teardown: () => void) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + function Probe() { + useDragTeardown(teardown) + return null + } + + act(() => { + root.render() + }) +} + +function fire(type: string) { + act(() => { + window.dispatchEvent(new Event(type, { bubbles: true })) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useDragTeardown', () => { + it('tears down on dragend', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on drop', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('drop') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on the first pointermove after a drag, which dragend can miss', () => { + // Spring-loading unmounts the source row, so `dragend` — dispatched at that node — never + // reaches window. Browsers suppress pointer events during a drag, so the first one after + // is an exact signal that the drag ended. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('never tears down from the passage of time alone', () => { + // Regression: an idle-timeout version of this tore the drag down whenever the user rested + // on a folder waiting for it to spring open — the drag model reports only about every + // 350ms while the pointer is still, so any timeout in that range trips on a held drag. + // Nothing here may depend on a timer, so advancing the clock must change nothing. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + act(() => { + vi.advanceTimersByTime(30_000) + }) + + expect(teardown).not.toHaveBeenCalled() + + // And the drag is still live, so a real end signal still lands. + fire('dragend') + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('ignores pointer movement when no drag is in flight', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('pointermove') + fire('pointermove') + + expect(teardown).not.toHaveBeenCalled() + }) + + it('tears down once per drag, not on every event after it ends', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + fire('pointermove') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts new file mode 100644 index 00000000000..a28ca2057a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Runs a drag's teardown wherever the drag actually ends. + * + * Three signals, because no single one is reliable here: + * + * 1. `drop` on `window` — a release over any valid target, wherever it bubbles from. + * 2. `dragend` on `window` — the normal end of a drag whose source row still exists. + * 3. `pointermove` on `window` — the case the first two miss. `dragend` is dispatched *at the + * source node*, so once spring-loading navigates the list and unmounts that row, the event + * has no path to `window` and neither listener above ever runs. Cancelling with Escape or + * releasing over nothing then leaves the ghost on the page, every row frozen at drag + * opacity, and the spring-open set uncleared so those folders refuse to open again. + * + * The third signal works because browsers suppress mouse and pointer events for the duration of + * a native drag: the first `pointermove` after one starts can only mean it is over. That makes + * it exact, where a timer is not — the drag model fires `dragover` on roughly a 350ms cadence + * while the pointer is stationary, so an idle-timeout version of this tore down mid-drag + * whenever the user rested on a folder waiting for it to spring open. + * + * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the + * callback would re-run this effect on every render, and the teardown wired into it would then + * abort drags that are still in progress — a bug this exact hook already shipped once. + */ +export function useDragTeardown(teardown: () => void): void { + const teardownRef = useRef<() => void>(teardown) + teardownRef.current = teardown + + useEffect(() => { + /** + * Set from `dragover` rather than `dragstart` so the flag only turns on once a drag is + * genuinely under way, and so a stray `pointermove` before the drag engages cannot tear + * down a drag that never started. + */ + let isDragging = false + + const markDragging = () => { + isDragging = true + } + + const endDrag = () => { + if (!isDragging) return + isDragging = false + teardownRef.current() + } + + window.addEventListener('dragover', markDragging) + window.addEventListener('dragend', endDrag) + window.addEventListener('drop', endDrag) + window.addEventListener('pointermove', endDrag) + return () => { + window.removeEventListener('dragover', markDragging) + window.removeEventListener('dragend', endDrag) + window.removeEventListener('drop', endDrag) + window.removeEventListener('pointermove', endDrag) + } + }, []) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 19eb8f3e7d2..a739111c83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -20,7 +20,12 @@ export interface UseFolderNavigationOptions { export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null - setCurrentFolderId: (folderId: string | null) => void + /** + * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose + * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen + * navigation, such as the second and later spring-opens within a single drag. + */ + setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -49,8 +54,8 @@ export function useFolderNavigation({ const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( - (folderId: string | null) => { - void setFolderParams({ folderId }) + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + void setFolderParams({ folderId }, options) }, [setFolderParams] ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index b6a746b2606..4ac69bb78b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,22 +1,83 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { + readRowDragPayload, + writeRowDragPayload, +} from '@/app/workspace/[workspaceId]/components/folders/drag-payload' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' +import { useRowDragGhost } from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' +import type { SpringOpenOptions } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { useSpringNavigation } from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' /** - * Private drag payload, namespaced so a drag started on another Sim surface (or an external - * drag) is never mistaken for a foldered list row. + * What the hook hands back: the render contract `Resource` consumes, plus the one signal that is + * not a rendering concern. */ -const DRAG_ROW_MIME = 'application/x-sim-foldered-row' - -const DRAG_GHOST_STYLE = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' +export interface FolderRowDragDrop extends RowDragDropConfig { + /** + * Reports that a page-level overlay consumed an external drop, so spring navigation keeps the + * folder it opened instead of returning to where the drag started. Only a surface that owns a + * whole-page drop target needs this; row, body, and breadcrumb drops report themselves. + */ + externalDropHandled: () => void +} /** Shared empty set so an idle drag state keeps a stable identity across renders. */ const EMPTY_ROW_IDS = new Set() +/** + * The one surface currently reading as "release here". + * + * A union rather than three booleans because the three targets are mutually exclusive: a row, + * the list body, and a breadcrumb crumb can never be armed together. As separate flags every + * handler had to hand-clear the other two, and where a `dragleave` does not fire — a row lives + * inside the scroll container, so moving onto it leaves that container with a contained + * `relatedTarget` its handler ignores — two affordances could paint at once. Here exactly one + * is armed by construction. + */ +type ActiveDropTarget = + | { kind: 'row'; rowId: string } + | { kind: 'body' } + | { kind: 'crumb'; index: number } + +/** + * Arms `next`, reusing the current value when it already names the same target. + * + * `dragover` fires continuously — several times a second even with the pointer still — so a + * fresh object per event would re-render the whole list and rebuild the memoized config every + * time. Returning `current` unchanged lets React bail on `Object.is`, which is what the plain + * string this union replaced used to get for free. + */ +function armDropTarget( + current: ActiveDropTarget | null, + next: ActiveDropTarget +): ActiveDropTarget | null { + if (current?.kind !== next.kind) return next + switch (next.kind) { + case 'row': + return current.kind === 'row' && current.rowId === next.rowId ? current : next + case 'crumb': + return current.kind === 'crumb' && current.index === next.index ? current : next + default: + return current + } +} + +/** Rows carried by one drag, already split by kind and stripped of no-op moves. */ +export interface FolderedRowMove { + folderIds: string[] + resourceIds: string[] +} + export interface UseFolderRowDragDropOptions { + /** + * This list's private drag MIME. Each surface owns one so a drag started in another list is + * never mistaken for one of these rows — see {@link writeRowDragPayload}. + */ + dragMime: string /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ canEdit: boolean /** Row currently being renamed inline, which must stay editable rather than draggable. */ @@ -29,10 +90,50 @@ export interface UseFolderRowDragDropOptions { getResourceFolderId: (resourceId: string) => string | null | undefined /** Label shown in the drag ghost. */ getRowLabel: (rowId: string) => string - /** Reparents a folder into `targetFolderId`. */ - onMoveFolder: (folderId: string, targetFolderId: string) => void - /** Files a resource into `targetFolderId`. */ - onMoveResource: (resourceId: string, targetFolderId: string) => void + /** + * Moves every row of the drag into `targetFolderId` in one call (`null` is the workspace + * root). Rows already sitting directly in the target are filtered out before this fires, and + * it is never called with both lists empty — so the consumer maps it straight onto its + * bulk-move operations. + */ + onMoveRows: (rows: FolderedRowMove, targetFolderId: string | null) => void + /** + * Checkbox selection, when the list has one. Dragging a selected row carries the whole + * selection; dragging an unselected row collapses the selection onto it first, matching + * every file manager. Omit on a list without selection to keep drags single-row. + */ + selection?: { + selectedRowIds: Set + /** Row ids in display order, so the drag carries them in the order they are read. */ + visibleRowIds: string[] + /** Collapses the selection onto a single row dragged from outside it. */ + replaceSelection: (rowIds: string[]) => void + } + /** + * Opens a folder the drag has rested on, so the user can file into a nested folder without + * dropping first. Forward `options` to the folder-navigation setter so one drag leaves one + * back-stack entry. Omit to disable spring-loading. See {@link useSpringNavigation}. + */ + onSpringOpenFolder?: (folderId: string | null, options: SpringOpenOptions) => void + /** + * The folder the list is currently showing (`null` at the workspace root). Enables dropping + * onto the list body to file into it — the only way to land a drag that spring-opened into an + * empty folder, which has no row to drop on. + */ + currentFolderId?: string | null + /** + * OS file drops, which Files accepts and the other lists do not. + * + * When `matches` recognises the drag, folder rows still highlight and still spring open — the + * gesture is the same, only the payload differs — but the internal move-validity gate is + * skipped, and the body and breadcrumb decline so a page-level upload overlay owns those + * regions rather than competing with it. + */ + externalDrop?: { + matches: (dataTransfer: DataTransfer) => boolean + /** Files released on a folder row, to be uploaded into it. */ + onDropIntoFolder: (dataTransfer: DataTransfer, targetFolderId: string) => void + } } /** @@ -40,77 +141,119 @@ export interface UseFolderRowDragDropOptions { * Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in * itself or its own subtree, and a row already sitting directly in the target is a no-op. * - * Single-row only, which is what the resource lists that use it support. The Files page - * keeps its own configuration because it additionally drags multi-selections and accepts - * external OS file drops. + * Carries a whole checkbox selection when `selection` is supplied, and a single row otherwise. + * Files layers OS file drops on top through `externalDrop`; the gesture is identical, only the + * payload differs. */ export function useFolderRowDragDrop({ + dragMime, canEdit, editingRowId, descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, -}: UseFolderRowDragDropOptions): RowDragDropConfig { - const [activeDropTargetId, setActiveDropTargetId] = useState(null) + onMoveRows, + selection, + onSpringOpenFolder, + currentFolderId = null, + externalDrop, +}: UseFolderRowDragDropOptions): FolderRowDragDrop { + const [activeDropTarget, setActiveDropTarget] = useState(null) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) /** * The in-flight drag source, mirrored outside React state because `onDragOver` fires far * faster than a re-render and must decide drop validity against the current source * synchronously. */ - const draggedRowIdRef = useRef(null) - const dragGhostRef = useRef(null) + const draggedRowIdsRef = useRef([]) const optionsRef = useRef({ descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + externalDrop, }) optionsRef.current = { descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + externalDrop, } + const springNav = useSpringNavigation({ currentFolderId, onNavigate: onSpringOpenFolder }) + + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const dragGhost = useRowDragGhost() + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + springNav.end() + dragGhost.remove() + draggedRowIdsRef.current = [] + setDraggedRowIds(EMPTY_ROW_IDS) + setActiveDropTarget(null) + }, [dragGhost, springNav]) + + useDragTeardown(endDrag) + /** - * The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which - * fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of - * view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on - * the page and every row frozen at drag opacity. Clean up on unmount as the backstop. + * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any + * row already sitting directly there. `null` when the drop is illegal outright — the target is + * one of the dragged folders or inside one, which would orphan a subtree into itself — or when + * nothing would actually change. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and `null` + * addresses the workspace root. */ - useEffect( - () => () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null + const resolveMoveToFolder = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]): FolderedRowMove | null => { + const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current + const folderIds: string[] = [] + const resourceIds: string[] = [] + + for (const sourceRowId of sourceRowIds) { + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') { + if (source.id === targetFolderId) return null + if (targetFolderId !== null && descendantsByFolderId.get(source.id)?.has(targetFolderId)) + return null + if ((getFolderParentId(source.id) ?? null) === targetFolderId) continue + folderIds.push(source.id) + continue + } + if ((getResourceFolderId(source.id) ?? null) === targetFolderId) continue + resourceIds.push(source.id) + } + + if (folderIds.length === 0 && resourceIds.length === 0) return null + return { folderIds, resourceIds } }, [] ) - const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { - const target = parseFolderedRowId(targetRowId) - if (target.kind !== 'folder') return true - - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') { - if (source.id === target.id) return true - if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true - return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id - } - return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id - }, []) + /** Row-targeted drop: only a folder row can receive one. */ + const resolveMove = useCallback( + (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return null + return resolveMoveToFolder(target.id, sourceRowIds) + }, + [resolveMoveToFolder] + ) - return useMemo( + return useMemo( () => ({ - activeDropTargetId, + activeDropTargetId: activeDropTarget?.kind === 'row' ? activeDropTarget.rowId : null, draggedRowIds, isAnyDragActive: draggedRowIds.size > 0, isRowDraggable: (rowId) => canEdit && editingRowId !== rowId, @@ -121,30 +264,46 @@ export function useFolderRowDragDrop({ return } - draggedRowIdRef.current = rowId - setDraggedRowIds(new Set([rowId])) + springNav.rememberOrigin() + const { selection } = optionsRef.current + /** + * Read the selection in display order rather than insertion order, so a shift-range + * drag carries its rows the way the user sees them. + */ + const sourceRowIds = selection?.selectedRowIds.has(rowId) + ? selection.visibleRowIds.filter((visibleRowId) => + selection.selectedRowIds.has(visibleRowId) + ) + : [rowId] + if (selection && !selection.selectedRowIds.has(rowId)) selection.replaceSelection([rowId]) + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData(DRAG_ROW_MIME, rowId) - e.dataTransfer.setData('text/plain', rowId) - - const ghost = document.createElement('div') - ghost.style.cssText = DRAG_GHOST_STYLE - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = optionsRef.current.getRowLabel(rowId) - ghost.appendChild(text) - document.body.appendChild(ghost) - // Force a layout pass so the drag image is measurable before it is captured. - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + writeRowDragPayload(e.dataTransfer, dragMime, sourceRowIds) + + dragGhost.attach(e, optionsRef.current.getRowLabel(sourceRowIds[0]), sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { - const sourceRowId = draggedRowIdRef.current - if (sourceRowId) { - if (isInvalidDropTarget(rowId, sourceRowId)) return - } else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) { + const sourceRowIds = draggedRowIdsRef.current + const isExternal = optionsRef.current.externalDrop?.matches(e.dataTransfer) ?? false + if (isExternal) { + /** + * An upload into a nested folder is the same gesture as a move into one, so the row + * highlights and springs open exactly the same way. Only the move-validity gate is + * skipped — there are no source rows to validate. + */ + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setActiveDropTarget((current) => armDropTarget(current, { kind: 'row', rowId })) + springNav.arm(parseFolderedRowId(rowId).id) + return + } + if (sourceRowIds.length > 0) { + if (!resolveMove(rowId, sourceRowIds)) return + } else if (!e.dataTransfer.types.includes(dragMime)) { /** * No local source and no payload of ours — an external or foreign drag. Returning * without `preventDefault` leaves the browser's default handling in place, which is @@ -165,38 +324,166 @@ export function useFolderRowDragDrop({ * would light up as a valid target — including the dragged folder itself and its own * descendants — and the drop would then silently do nothing. */ - if (sourceRowId) setActiveDropTargetId(rowId) + if (sourceRowIds.length > 0) { + setActiveDropTarget((current) => armDropTarget(current, { kind: 'row', rowId })) + /** + * Armed on the same condition as the highlight, so a folder only springs open where a + * drop was already possible. A folder the drag cannot legally enter never opens. + */ + springNav.arm(parseFolderedRowId(rowId).id) + } }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return - setActiveDropTargetId((current) => (current === rowId ? null : current)) + springNav.disarm() + setActiveDropTarget((current) => + current?.kind === 'row' && current.rowId === rowId ? null : current + ) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - setActiveDropTargetId(null) const target = parseFolderedRowId(rowId) - if (target.kind !== 'folder') return - + const { externalDrop } = optionsRef.current + if (externalDrop?.matches(e.dataTransfer)) { + const { dataTransfer } = e + /** + * Marked before `endDrag`, which consumes the flag: an upload lands in the folder the + * drag opened, so the view has to stay there rather than springing back to the origin. + */ + if (target.kind === 'folder') springNav.markDropHandled() + endDrag() + if (target.kind === 'folder') externalDrop.onDropIntoFolder(dataTransfer, target.id) + return + } // Prefer the dataTransfer payload over the ref so a drag that started in another - // mount of this page still resolves to a real row id. - const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current - if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + // mount of this page still resolves to real row ids. + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + const move = + target.kind === 'folder' && sourceRowIds.length > 0 + ? resolveMove(rowId, sourceRowIds) + : null + if (move) springNav.markDropHandled() - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id) - else optionsRef.current.onMoveResource(source.id, target.id) + /** + * Ends the drag here rather than leaving it to `dragend`. This handler stops + * propagation, so the window-level backstop never sees this drop, and the source row + * may already have unmounted — after a spring-open it always has. + */ + endDrag() + + if (move) optionsRef.current.onMoveRows(move, target.id) + }, + onDragEnd: endDrag, + externalDropHandled: springNav.markDropHandled, + /** + * The breadcrumb is how a drag walks back UP. Spring-loading only ever goes deeper, so + * without this a drag that entered a folder can only leave it by being abandoned. + * Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on + * one files the drag there directly. + */ + breadcrumb: { + activeIndex: activeDropTarget?.kind === 'crumb' ? activeDropTarget.index : null, + onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + const canDrop = + sourceRowIds.length > 0 && resolveMoveToFolder(folderId, sourceRowIds) !== null + /** + * Armed even when the drop itself would be a no-op — walking back through a crumb the + * rows already live in is exactly how a user returns to where they started, and + * refusing to navigate there would strand them. The crumb for the folder already on + * screen is declined by {@link useSpringNavigation}, not here. + */ + if (sourceRowIds.length > 0) springNav.arm(folderId) + setActiveDropTarget((current) => + canDrop ? armDropTarget(current, { kind: 'crumb', index }) : null + ) + if (!canDrop) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (_e: DragEvent, index: number) => { + springNav.disarm() + setActiveDropTarget((current) => + current?.kind === 'crumb' && current.index === index ? null : current + ) + }, + onDrop: (e: DragEvent, folderId: string | null) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, folderId) + }, }, - onDragEnd: () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null - draggedRowIdRef.current = null - setDraggedRowIds(EMPTY_ROW_IDS) - setActiveDropTargetId(null) + body: { + isActive: activeDropTarget?.kind === 'body', + onDragOver: (e: DragEvent) => { + /** Declined: a page-level upload overlay owns the whole region for an OS file drag. */ + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + /** + * Recomputed on every event rather than latched, because a spring-open changes the + * destination mid-drag: the folder just entered may not accept this drag, and an + * early return would leave the body overlay showing from the previous folder. Setting + * the same value repeatedly is free — React bails on an unchanged state write. + */ + const canDrop = + sourceRowIds.length > 0 && + resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null + setActiveDropTarget((current) => + canDrop ? armDropTarget(current, { kind: 'body' }) : null + ) + if (!canDrop) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setActiveDropTarget((current) => (current?.kind === 'body' ? null : current)) + }, + onDrop: (e: DragEvent) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + /** + * Read from the ref, not the closure. This config is memoized, and during a drag the + * only dep that routinely changes is the hovered row — so after a spring-open into an + * empty folder, which has no rows to hover, a captured `currentFolderId` would still + * name the folder the drag came FROM and file the rows back into it. + */ + const targetFolderId = currentFolderIdRef.current + const move = + sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, targetFolderId) + }, }, }), - [activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget] + [ + activeDropTarget, + draggedRowIds, + canEdit, + dragMime, + editingRowId, + resolveMove, + resolveMoveToFolder, + springNav, + endDrag, + dragGhost, + ] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx new file mode 100644 index 00000000000..6aa96a1d3e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { + type RowDragGhost, + useRowDragGhost, +} from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' + +const mountedRoots: Root[] = [] + +function renderGhost() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: RowDragGhost | undefined + + function Probe() { + result = useRowDragGhost() + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +describe('useRowDragGhost', () => { + it('keeps a stable handle identity across renders', () => { + // Consumers list this handle in the deps of the `endDrag` callback that `useDragTeardown` + // binds and the drag config memo depends on. A fresh object per render makes `endDrag` + // unstable and rebuilds the whole drag config every render. + const ghost = renderGhost() + + const before = ghost.get() + ghost.rerender() + ghost.rerender() + + expect(ghost.get()).toBe(before) + }) + + it('removes the ghost node on unmount', () => { + const ghost = renderGhost() + const dataTransfer = { setDragImage: () => {} } as unknown as DataTransfer + + act(() => { + ghost.get().attach({ dataTransfer } as never, 'Report.md', 1) + }) + expect(document.body.children.length).toBe(1) + + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + expect(document.body.children.length).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts new file mode 100644 index 00000000000..ee37ca45a8c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts @@ -0,0 +1,66 @@ +'use client' + +import type { DragEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * Inline chrome for the drag image. It is set on a detached DOM node handed to `setDragImage`, + * so it cannot be a Tailwind class list. + * + * `font-family` is deliberately absent: the node is appended to ``, so omitting it lets + * the label inherit the app font and match the row it was lifted from. + */ +const DRAG_GHOST_STYLE = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + +const DRAG_GHOST_LABEL_STYLE = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + +export interface RowDragGhost { + /** Builds the drag image for this drag and attaches it to the event. */ + attach: (e: DragEvent, label: string, count: number) => void + /** Removes the ghost node. Safe to call when none is attached. */ + remove: () => void +} + +/** + * The drag image shown while dragging resource rows — the first row's label, plus a count when + * the drag carries a multi-row selection. + * + * Shared so every foldered list lifts rows the same way. The node lives on `document.body` + * rather than in the React tree because `setDragImage` snapshots a real, laid-out element; the + * unmount cleanup is the backstop for a drag whose source row disappears before it ends. + */ +export function useRowDragGhost(): RowDragGhost { + const ghostRef = useRef(null) + + const remove = useCallback(() => { + ghostRef.current?.remove() + ghostRef.current = null + }, []) + + useEffect(() => remove, [remove]) + + const attach = useCallback((e: DragEvent, label: string, count: number) => { + const ghost = document.createElement('div') + ghost.style.cssText = DRAG_GHOST_STYLE + const text = document.createElement('span') + text.style.cssText = DRAG_GHOST_LABEL_STYLE + text.textContent = count > 1 ? `${label} +${count - 1} more` : label + ghost.appendChild(text) + document.body.appendChild(ghost) + // Force a layout pass so the drag image is measurable before it is captured. + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + ghostRef.current = ghost + }, []) + + /** + * Stable identity, not a fresh object per render — the same requirement + * {@link useSpringLoadedFolder} documents. Consumers list this handle in the deps of the + * `endDrag` callback that `useDragTeardown` binds and that the drag-config memo depends on, + * so a new object each render makes `endDrag` unstable and rebuilds the whole drag config + * every render, re-rendering every memoized row. The inner callbacks are already stable, so + * this memo never invalidates. + */ + return useMemo(() => ({ attach, remove }), [attach, remove]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx new file mode 100644 index 00000000000..e2b3b9d5278 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx @@ -0,0 +1,235 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SPRING_LOAD_DELAY_MS, + type SpringLoadedFolder, + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +const mountedRoots: Root[] = [] + +interface SpringLoadHarness { + get: () => SpringLoadedFolder + /** Re-renders the probe with a new inline callback, as a parent re-render would. */ + rerender: () => void +} + +function renderSpringLoad( + onSpringOpen: (folderId: string, options: SpringOpenOptions) => void +): SpringLoadHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: SpringLoadedFolder | undefined + + function Probe() { + // A fresh arrow each render, mirroring how every real consumer passes this. + result = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => onSpringOpen(folderId, options), + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +/** Advances past the spring delay, flushing the timer callback inside React's act scope. */ +function rest(ms = SPRING_LOAD_DELAY_MS) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringLoadedFolder', () => { + it('opens the folder after the drag rests on it', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 1) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest(1) + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('does not restart the countdown while the drag stays on one folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + // `dragover` fires continuously; re-arming the same folder must not push the deadline back. + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-a')) + rest(100) + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('restarts the countdown when the drag moves to another folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-b')) + rest(100) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest() + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-b', { history: 'push' }) + }) + + it('cancels the pending open when the drag leaves the row', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => harness.get().disarm()) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) + + it('opens a folder again when the drag comes back to it', () => { + // Descend, walk back out through the breadcrumb, change your mind and descend again — one + // gesture, and the second entry has to work. Re-entry costs another full delay, and + // `useSpringNavigation` refuses the folder already on screen, so nothing oscillates. + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm(null)) + rest() + act(() => harness.get().arm('folder-a')) + rest() + + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + [null, { history: 'replace' }], + ['folder-a', { history: 'replace' }], + ]) + }) + + it('pushes the first spring-open of a drag and replaces the rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm('folder-b')) + rest() + + // One gesture, one back-stack entry: Back returns to where the drag started rather than + // replaying every level it passed through, and without eating the entry it started on. + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + ['folder-b', { history: 'replace' }], + ]) + + // A new drag is a new gesture, so its first open pushes again. + act(() => harness.get().reset()) + act(() => harness.get().arm('folder-c')) + rest() + expect(onSpringOpen).toHaveBeenLastCalledWith('folder-c', { history: 'push' }) + }) + + it('keeps a stable handle identity across renders', () => { + // Regression guard: consumers feed this handle into a `useCallback` that a drag-lifecycle + // effect depends on. A fresh object per render re-runs that effect continuously, which tore + // down in-flight drags and silently disabled spring-loading entirely. + const harness = renderSpringLoad(vi.fn()) + + const before = harness.get() + harness.rerender() + harness.rerender() + + expect(harness.get()).toBe(before) + }) + + it('allows the same folder again after the drag ends', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().reset()) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('springs to the workspace root, which a breadcrumb targets as null', () => { + // Walking a drag back UP goes through the breadcrumb, whose first crumb is the root — so + // null has to be a real destination here, distinct from "nothing armed". + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' }) + }) + + it('re-opens the root like any other folder, and only after a full rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + // Passing over another row cancels the countdown, so returning to the root has to wait out + // the delay again rather than firing on whatever was left of the previous one. + act(() => harness.get().arm('folder-a')) + act(() => harness.get().arm(null)) + expect(onSpringOpen).toHaveBeenCalledTimes(1) + rest() + + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('never opens a folder after unmount', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts new file mode 100644 index 00000000000..3f39c951fc0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -0,0 +1,121 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * How long a drag must rest on a folder before it opens. + * + * Deliberately slower than the workflow sidebar's 400ms hover-to-expand: that one opens a tree + * node in place and is trivially reversible, while this one navigates the whole list view out + * from under the drag. At 700ms a drag merely crossing a folder on its way elsewhere kept + * triggering it; the cost of waiting is far lower than the cost of an unwanted navigation. + */ +export const SPRING_LOAD_DELAY_MS = 1000 + +/** How a spring-open writes the newly opened folder to the browser history. */ +export interface SpringOpenOptions { + history: 'push' | 'replace' +} + +export interface UseSpringLoadedFolderOptions { + /** + * Opens the folder mid-drag. The drag continues in the newly opened folder. + * + * `options.history` is `'push'` for the first folder a drag opens and `'replace'` for every + * one after, so one gesture leaves exactly one back-stack entry and Back returns to the + * folder the drag started in. Pushing every level would record folders the user only rested + * over while deciding where to drop; replacing every level would overwrite the entry they + * were actually standing on, so Back would leave the page instead of returning to it. + */ + onSpringOpen: (folderId: string | null, options: SpringOpenOptions) => void + delayMs?: number +} + +export interface SpringLoadedFolder { + /** + * Starts (or continues) the timer for `folderId`. Safe to call on every `dragover`, which + * fires continuously: re-arming the folder already being timed does not restart it, so the + * countdown reflects how long the drag has actually rested there. + */ + arm: (folderId: string | null) => void + /** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */ + disarm: () => void + /** Cancels the pending open and forgets that this drag opened anything. Call when the drag ends. */ + reset: () => void +} + +/** + * Spring-loaded folders: resting a drag on a folder row opens it, so a resource can be filed + * into a nested folder in one gesture instead of being dropped, navigated, and dragged again. + * + * The dragged rows unmount when the list re-renders into the newly opened folder, which is why + * the drag payload has to live in `dataTransfer` rather than only in the source row's state. + * + * A folder may open more than once in a single drag: walking back out through the breadcrumb and + * descending again is a normal way to change your mind mid-gesture, and refusing the second entry + * strands the drag one level up. Nothing oscillates, because every open costs another full + * {@link SPRING_LOAD_DELAY_MS} of the drag holding still, and {@link useSpringNavigation} refuses + * to arm the folder already on screen. + */ +export function useSpringLoadedFolder({ + onSpringOpen, + delayMs = SPRING_LOAD_DELAY_MS, +}: UseSpringLoadedFolderOptions): SpringLoadedFolder { + const timerRef = useRef | null>(null) + /** + * Folder the timer is counting down for, so re-arming it is a no-op. `undefined` means + * nothing is armed — `null` is a real destination here, the workspace root. + */ + const armedFolderIdRef = useRef(undefined) + /** Whether this drag has already sprung a folder open, which decides push vs. replace. */ + const hasOpenedRef = useRef(false) + + const onSpringOpenRef = useRef(onSpringOpen) + onSpringOpenRef.current = onSpringOpen + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + timerRef.current = null + armedFolderIdRef.current = undefined + }, []) + + /** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */ + useEffect(() => clearTimer, [clearTimer]) + + const arm = useCallback( + (folderId: string | null) => { + if (armedFolderIdRef.current === folderId) return + + /** + * The drag has moved to a different row, so any countdown started on the previous one is + * stale — cancel it before deciding whether this row can spring. Returning early without + * this would let the folder the drag just left open behind the cursor. + */ + clearTimer() + armedFolderIdRef.current = folderId + timerRef.current = setTimeout(() => { + timerRef.current = null + armedFolderIdRef.current = undefined + const isFirstOpenOfDrag = !hasOpenedRef.current + hasOpenedRef.current = true + onSpringOpenRef.current(folderId, { + history: isFirstOpenOfDrag ? 'push' : 'replace', + }) + }, delayMs) + }, + [clearTimer, delayMs] + ) + + const reset = useCallback(() => { + clearTimer() + hasOpenedRef.current = false + }, [clearTimer]) + + /** + * Stable identity, not a fresh object per render. Consumers feed this handle into a + * `useCallback` that a drag-lifecycle effect depends on; a new object each render re-runs + * that effect continuously, and its cleanup then tears down the drag that is still in + * progress. The inner callbacks are already stable, so this memo never invalidates. + */ + return useMemo(() => ({ arm, disarm: clearTimer, reset }), [arm, clearTimer, reset]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx new file mode 100644 index 00000000000..6e3590e2d48 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx @@ -0,0 +1,309 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SPRING_LOAD_DELAY_MS } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { + type SpringNavigation, + useSpringNavigation, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' + +const mountedRoots: Root[] = [] + +/** + * Drives the hook the way a drag does, re-rendering with the folder the navigation callback + * just moved to — the hook compares the origin against the *current* folder, so a probe that + * never advances would never exercise the return path. + */ +function renderSpringNavigation(startFolderId: string | null) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + const navigate = vi.fn<(folderId: string | null, history: 'push' | 'replace') => void>() + let currentFolderId = startFolderId + let result: SpringNavigation | undefined + + function Probe({ folderId }: { folderId: string | null }) { + result = useSpringNavigation({ + currentFolderId: folderId, + onNavigate: (nextFolderId, options) => { + navigate(nextFolderId, options.history) + currentFolderId = nextFolderId + }, + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + navigate, + currentFolderId: () => currentFolderId, + } +} + +/** + * Advances past the spring delay, then re-renders with the folder the navigation moved to — + * the real list does the same, and the hook compares the origin against the *current* folder, + * so a probe stuck on the old one would never exercise the return path. + */ +function rest(harness: { rerender: () => void }) { + act(() => { + vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS) + }) + harness.rerender() +} + +/** One spring-open: rest the drag on `folderId` until the timer fires and the list follows. */ +function descend(nav: ReturnType, folderId: string | null) { + act(() => nav.get().arm(folderId)) + rest(nav) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringNavigation', () => { + it('opens a folder the drag rests on', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + }) + + it('returns to the origin when the drag ends without a drop', () => { + // The whole point: spring-loading only goes deeper, so a cancelled drag would otherwise + // strand the user in a folder they never chose to open. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenLastCalledWith(null, 'replace') + expect(nav.currentFolderId()).toBeNull() + }) + + it('stays put when a drop actually landed', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + expect(nav.currentFolderId()).toBe('folder-a') + }) + + it('returns in one hop from several levels deep', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-a', 'push'], + ['folder-b', 'replace'], + [null, 'replace'], + ]) + expect(nav.currentFolderId()).toBeNull() + }) + + it('returns to a subfolder origin, not the root', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.currentFolderId()).toBe('origin-folder') + }) + + it('does not navigate when the drag never opened anything', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().end()) + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + describe('walking a drag back out and in again', () => { + it('re-enters a folder it already left through the breadcrumb', () => { + // The whole point of the breadcrumb accepting a drag: descend, think better of it, walk + // back up, then descend again — all inside one gesture without releasing the mouse. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + expect(nav.currentFolderId()).toBe('folder-a') + + descend(nav, null) + expect(nav.currentFolderId()).toBeNull() + + descend(nav, 'folder-a') + expect(nav.currentFolderId()).toBe('folder-a') + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-a', 'push'], + [null, 'replace'], + ['folder-a', 'replace'], + ]) + }) + + it('never re-opens the folder already on screen', () => { + // The crumb for the current folder is a legal drop target but not a navigation. Arming it + // would re-enter the folder the drag is already standing in, on a loop. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + + descend(nav, 'folder-a') + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + }) + + it('cancels a pending open when the drag moves onto the current folder', () => { + // Hovering a sibling folder starts its countdown; sliding onto the crumb of the folder + // you are already in has to call that off, not let it fire from under the cursor. + const nav = renderSpringNavigation('folder-a') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-b')) + act(() => { + vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS - 1) + }) + descend(nav, 'folder-a') + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + it('returns to the origin in one hop after a round trip that dropped nothing', () => { + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, 'folder-b') + descend(nav, 'folder-a') + + nav.navigate.mockClear() + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('origin', 'replace') + expect(nav.currentFolderId()).toBe('origin') + }) + + it('stays put when the round trip ends in a real drop', () => { + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, null) + descend(nav, 'folder-a') + + nav.navigate.mockClear() + act(() => { + nav.get().markDropHandled() + nav.get().end() + }) + + expect(nav.navigate).not.toHaveBeenCalled() + expect(nav.currentFolderId()).toBe('folder-a') + }) + + it('walks back to the origin folder itself without then bouncing away from it', () => { + // Ending a drag whose spring-opens happen to land back on the origin must not navigate + // again — the guard is origin-vs-current, not "did anything open". + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, 'origin') + expect(nav.currentFolderId()).toBe('origin') + + nav.navigate.mockClear() + act(() => nav.get().end()) + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + it('starts the next drag from where the previous one left the user', () => { + // A drag that ended on a new folder is the new origin. Reusing the old one would yank the + // list back several folders on the next unrelated drag. + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + act(() => { + nav.get().markDropHandled() + nav.get().end() + }) + + nav.navigate.mockClear() + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-b') + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-b', 'push'], + ['folder-a', 'replace'], + ]) + }) + }) + + it('does not carry drop state into the next drag', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + nav.navigate.mockClear() + + // A second drag that lands nowhere must still return, despite the first one having dropped. + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-b', 'push'], + ['folder-a', 'replace'], + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts new file mode 100644 index 00000000000..1e0e208f4ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts @@ -0,0 +1,128 @@ +'use client' + +import { useCallback, useMemo, useRef } from 'react' +import { + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +export interface UseSpringNavigationOptions { + /** The folder the list is currently showing (`null` at the workspace root). */ + currentFolderId: string | null + /** + * Navigates the list. Called both to spring a folder open mid-drag and to return to where the + * drag began. Omit to disable spring navigation entirely. + */ + onNavigate?: (folderId: string | null, options: SpringOpenOptions) => void +} + +export interface SpringNavigation { + /** Records where this drag began. Call from `dragstart`. */ + rememberOrigin: () => void + /** Starts (or continues) the timer that opens `folderId`. Safe to call on every `dragover`. */ + arm: (folderId: string | null) => void + /** Cancels a pending open — the drag left the target. */ + disarm: () => void + /** Marks that a drop actually moved something, so the destination stays on screen. */ + markDropHandled: () => void + /** Ends the drag, returning to the origin when the spring-opens went unused. */ + end: () => void +} + +/** + * Spring-loaded folder navigation for a drag, including the way back out. + * + * Opening a folder mid-drag is only half a gesture. A drag that springs its way several levels + * deep and is then cancelled — or dropped somewhere else — would otherwise leave the user in a + * folder they never chose to open, looking at a list they did not ask for. So the navigation is + * treated as part of the drag: unless a drop actually landed, ending the drag returns to where + * it started. The workflow sidebar collapses its own spring-opened folders for the same reason. + * + * Shared by every foldered list, including a drag of OS files onto the Files page. + */ +export function useSpringNavigation({ + currentFolderId, + onNavigate, +}: UseSpringNavigationOptions): SpringNavigation { + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const onNavigateRef = useRef(onNavigate) + onNavigateRef.current = onNavigate + + const originFolderIdRef = useRef(null) + /** Whether this drag has an origin yet. A drag of OS files fires no `dragstart` of ours. */ + const hasOriginRef = useRef(false) + const didSpringOpenRef = useRef(false) + const dropHandledRef = useRef(false) + + const springLoad = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => { + didSpringOpenRef.current = true + onNavigateRef.current?.(folderId, options) + }, + }) + + const rememberOrigin = useCallback(() => { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + }, []) + + /** + * Seeds the origin for a drag that never reached {@link SpringNavigation.rememberOrigin} — a + * drag of OS files starts outside the page, so there is no `dragstart` of ours to record it. + * Without this the return lands on whatever folder the PREVIOUS drag began in. + * + * Refuses the folder already on screen. That target is not a navigation, and arming it is how + * a drag resting on one spot would re-open the same folder over and over: the underlying timer + * lets a folder spring more than once per drag so the user can descend, back out through the + * breadcrumb, and descend again. + */ + const arm = useCallback( + (folderId: string | null) => { + if (folderId === currentFolderIdRef.current) { + springLoad.disarm() + return + } + if (!hasOriginRef.current) { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + } + springLoad.arm(folderId) + }, + [springLoad.arm, springLoad.disarm] + ) + + const markDropHandled = useCallback(() => { + dropHandledRef.current = true + }, []) + + const end = useCallback(() => { + /** + * `replace`, not `push`: the spring-opens are being undone, so they should leave no trace in + * the back stack rather than a trail the user has to walk back out of. + */ + if ( + didSpringOpenRef.current && + !dropHandledRef.current && + originFolderIdRef.current !== currentFolderIdRef.current + ) { + onNavigateRef.current?.(originFolderIdRef.current, { history: 'replace' }) + } + didSpringOpenRef.current = false + dropHandledRef.current = false + hasOriginRef.current = false + springLoad.reset() + }, [springLoad]) + + return useMemo( + () => ({ + rememberOrigin, + arm, + disarm: springLoad.disarm, + markDropHandled, + end, + }), + [rememberOrigin, arm, springLoad.disarm, markDropHandled, end] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..3aeaaeebbeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -4,8 +4,11 @@ export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' +export type { BulkOutcome } from './resource/bulk-outcome' +export { reportBulkOutcome } from './resource/bulk-outcome' export { FloatingOverflowText } from './resource/components/floating-overflow-text' -export { ownerCell } from './resource/components/owner-cell' +export type { OwnerAvatarProps } from './resource/components/owner-cell' +export { OwnerAvatar, ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, ResourceChromeFallback, @@ -24,7 +27,10 @@ export type { SearchTag, SortConfig, } from './resource/components/resource-options' -export { SortDropdown } from './resource/components/resource-options' +export { + FILTER_SECTION_LABEL_CLASS, + SortDropdown, +} from './resource/components/resource-options' export { timeCell } from './resource/components/time-cell' export type { PaginationConfig, @@ -37,5 +43,8 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { selectionLabel } from './resource/selection-label' +export type { ResourceRowSelection } from './resource/use-resource-row-selection' +export { useResourceRowSelection } from './resource/use-resource-row-selection' export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts new file mode 100644 index 00000000000..2d2817a8bd0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts @@ -0,0 +1,53 @@ +import { toast } from '@sim/emcn' + +/** An item the batch reached but could not act on, with a reason worth showing. */ +interface BulkFailure { + name: string + reason: string +} + +/** An id that resolved to nothing active — deleted, or not visible to this user. */ +interface BulkMissing { + id: string +} + +export interface BulkOutcome { + failed: BulkFailure[] + notFound: BulkMissing[] +} + +/** + * Reports the parts of a bulk operation that did not happen. + * + * A bulk request succeeds as a whole while individual items are refused (a delete lock, a + * folder cycle) or have vanished since the list was rendered. Those items are the difference + * between what the user selected and what actually changed, so they have to be said out loud — + * the list simply refetching leaves the user to notice a row survived. + * + * Success stays silent, matching the single-item move and delete paths. + * + * @param verb Past-tense verb for the failure sentence, e.g. `'moved'` or `'deleted'`. + */ +export function reportBulkOutcome(outcome: BulkOutcome, verb: string): void { + const { failed, notFound } = outcome + + if (failed.length > 0) { + const [first] = failed + toast.error( + failed.length === 1 + ? `${first.name} could not be ${verb}: ${first.reason}` + : `${failed.length} items could not be ${verb}. ${first.name}: ${first.reason}`, + { duration: 5000 } + ) + return + } + + if (notFound.length > 0) { + toast.error( + notFound.length === 1 + ? `One item was no longer available and was not ${verb}.` + : `${notFound.length} items were no longer available and were not ${verb}.`, + { duration: 5000 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..afbe7593240 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -0,0 +1,163 @@ +'use client' + +import type { ComponentType } from 'react' +import { + Button, + chipFilledFillTokens, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash, +} from '@sim/emcn' +import { Download } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' + +/** Shared chrome for every action button, so the bar reads as one control strip. */ +const ACTION_BUTTON_CLASS = cn( + chipFilledFillTokens, + 'hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' +) + +interface ActionButtonProps { + icon: ComponentType<{ className?: string }> + label: string + onClick: () => void + disabled?: boolean +} + +function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProps) { + return ( + + + + + {label} + + ) +} + +export interface ResourceActionBarProps { + /** The bar is mounted only while this is above zero; it animates in and out on the edges. */ + selectedCount: number + /** Omit on lists with nothing to download (tables, knowledge bases). */ + onDownload?: () => void + /** Both `onMove` and `moveOptions` are required for the move menu to appear. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + /** Disables every action while a bulk mutation is in flight. */ + isLoading?: boolean + /** + * Largest selection the bulk endpoints accept. Past it the server rejects the whole request, + * so the bar says so and disables the actions rather than letting the user confirm something + * that cannot succeed. + */ + maxSelectable?: number + className?: string +} + +/** + * Floating bulk-action bar for a `Resource.Table` with checkbox selection, shared so Files, + * Tables, and Knowledge present the same strip in the same place. + * + * Actions are ordered to mirror the row context menu — move before delete, destructive last. + * Each action is opt-in: a list that cannot perform one simply omits its handler, and a reader + * omits the ones they lack permission for. + * + * The entrance is a CSS animation rather than framer-motion: this bar is reachable from three + * list pages, and an animation library on that path costs every one of them ~40 modules of page + * weight for one fade. The trade is that dismissal is instant — an exit animation needs presence + * tracking, which is the part that pulls the library back in. + */ +export function ResourceActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + maxSelectable, + className, +}: ResourceActionBarProps) { + if (selectedCount === 0) return null + + const exceedsLimit = maxSelectable !== undefined && selectedCount > maxSelectable + const actionsDisabled = isLoading || exceedsLimit + + return ( +
+
+ + {exceedsLimit + ? `${selectedCount} selected · select ${maxSelectable} or fewer` + : `${selectedCount} selected`} + +
+ {onDownload && ( + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {renderMoveOptions(moveOptions, onMove)} + + + )} + {onDelete && ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts new file mode 100644 index 00000000000..c2c5faaeb72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts @@ -0,0 +1,2 @@ +export type { ResourceActionBarProps } from './action-bar' +export { ResourceActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts index fa102e05d3a..22f3365aa43 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts @@ -1 +1,2 @@ -export { ownerCell } from './owner-cell' +export type { OwnerAvatarProps } from './owner-cell' +export { OwnerAvatar, ownerCell } from './owner-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 23a845af1b6..ebbf3bb9e9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -2,12 +2,17 @@ import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -interface OwnerAvatarProps { +export interface OwnerAvatarProps { name: string image: string | null } -const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { +/** + * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral + * disc. Shared so a member reads identically in a resource row's owner cell and in the + * owner/uploaded-by filter options on every list. + */ +export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( void dropdownItems?: DropdownOption[] @@ -95,6 +103,19 @@ export interface ResourceAction { disabled?: boolean } +/** + * Makes breadcrumb crumbs drag destinations, so a drag can walk back up the tree it walked + * into. Hovering a crumb navigates to it after the same delay a folder row uses, and releasing + * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. + */ +export interface BreadcrumbDropConfig { + /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ + activeIndex: number | null + onDragOver: (e: DragEvent, folderId: string | null, index: number) => void + onDragLeave: (e: DragEvent, index: number) => void + onDrop: (e: DragEvent, folderId: string | null) => void +} + interface ResourceHeaderProps { icon?: React.ElementType title?: string @@ -109,6 +130,7 @@ interface ResourceHeaderProps { * in `actions`; never stuff primary actions in here. */ aside?: ReactNode + breadcrumbDrop?: BreadcrumbDropConfig } export const ResourceHeader = memo(function ResourceHeader({ @@ -117,6 +139,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, actions, aside, + breadcrumbDrop, }: ResourceHeaderProps) { const headerRef = useRef(null) /** @@ -164,6 +187,22 @@ export const ResourceHeader = memo(function ResourceHeader({ */ const showLocationPopover = LocationIcon != null + /** + * Only a crumb that names a folder is a destination; a trailing detail segment + * has no `folderId` and stays inert. + */ + const crumbDrag = + breadcrumbDrop && crumb.folderId !== undefined + ? { + isActive: breadcrumbDrop.activeIndex === i, + onDragOver: (e: DragEvent) => + breadcrumbDrop.onDragOver(e, crumb.folderId as string | null, i), + onDragLeave: (e: DragEvent) => breadcrumbDrop.onDragLeave(e, i), + onDrop: (e: DragEvent) => + breadcrumbDrop.onDrop(e, crumb.folderId as string | null), + } + : undefined + return ( {i > 0 && ( @@ -177,6 +216,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs={breadcrumbs} className={segmentClassName} veilBoundaryRef={headerRef} + drag={crumbDrag} /> ) : ( )} @@ -269,6 +310,13 @@ interface BreadcrumbSegmentProps { dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing className?: string + /** Drag handlers plus the active flag, when this crumb is a drag destination. */ + drag?: { + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void + } } const BreadcrumbSegment = memo(function BreadcrumbSegment({ @@ -278,6 +326,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ dropdownItems, editing, className, + drag, }: BreadcrumbSegmentProps) { const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing() const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) => @@ -318,7 +367,18 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ - @@ -345,8 +405,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ ) : null} {search.dropdown && (
{search.dropdown}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..31ca47ae1bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -16,8 +16,11 @@ import { Button, Checkbox, cellIconNodeClass, + chipActiveSurfaceClass, chipContentGap, chipContentLabelClass, + chipDropTargetSurfaceClass, + chipHoverSurfaceClass, cn, Loader, } from '@sim/emcn' @@ -25,6 +28,7 @@ import { ChevronLeft, ChevronRight, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import type { BreadcrumbDropConfig } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' @@ -83,6 +87,20 @@ export interface SelectableConfig { disabled?: boolean } +/** + * Drop onto the list body, which files into the folder currently open. + * + * Rows alone are not enough: a drag that spring-opens into an empty folder has nothing to land + * on, so without this the gesture dead-ends and the item cannot be moved there at all. + */ +export interface BodyDropConfig { + /** The drag is over the body and releasing would move something. */ + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void +} + export interface RowDragDropConfig { activeDropTargetId?: string | null draggedRowIds?: Set @@ -94,6 +112,9 @@ export interface RowDragDropConfig { onDragLeave?: (e: DragEvent, rowId: string) => void onDrop?: (e: DragEvent, rowId: string) => void onDragEnd?: (e: DragEvent, rowId: string) => void + body?: BodyDropConfig + /** Passed to `Resource.Header` so the breadcrumb can receive the same drag. */ + breadcrumb?: BreadcrumbDropConfig } export interface PaginationConfig { @@ -291,6 +312,7 @@ const ResourceTable = memo(function ResourceTable({ }, [onLoadMore, hasMore]) const hasCheckbox = selectable != null + const bodyDrop = rowDragDrop?.body const handleSelectAll = useCallback( (checked: boolean | 'indeterminate') => { @@ -334,7 +356,13 @@ const ResourceTable = memo(function ResourceTable({ return (
-
+
)}
+ {bodyDrop?.isActive && ( + /** + * A soft tint over the whole list region, not a line around it. This is the workflow + * sidebar's own drop-inside affordance (`bg-[var(--text-subtle)] opacity-10`), and it + * is the right weight here: a hairline stretched around the entire pane reads as a + * window border rather than a drop target, and being painted at the scrollport edge it + * also got its corners shaved by the parent's `overflow-hidden`. A fill has no corners + * to clip and no edge to fight the surrounding chrome. + */ +
+ )} {overlay} {pagination && pagination.totalPages > 1 && ( 0 + /** Hover and active are mutually exclusive, so a selected row holds its surface through hover. */ + const isRowActive = selectedRowId === row.id || isSelected || isContextMenuTarget const handleClick = useCallback( (e: React.MouseEvent) => { @@ -664,16 +708,15 @@ const DataRow = memo(function DataRow({ className={cn( 'grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', - !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', + !isAnyDragActive && !isRowActive && chipHoverSurfaceClass, onRowClick && 'cursor-pointer', isDraggable && 'cursor-grab active:cursor-grabbing', - isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]', - (selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]', - isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]', + isRowActive && chipActiveSurfaceClass, + /** See {@link chipDropTargetSurfaceClass} for why this is neutral and drawn inset. */ + isActiveDropTarget && chipDropTargetSurfaceClass, (isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50' )} style={rowStyle} - data-drop-target={isDropTarget || undefined} draggable={isDraggable} onClick={onRowClick || selectable ? handleClick : undefined} onMouseEnter={handleMouseEnter} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts new file mode 100644 index 00000000000..fc812dbee55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts @@ -0,0 +1,9 @@ +/** + * Names a multi-row selection for a confirmation prompt: one row reads as itself, several read + * as a count. Shared so the wording stays identical across every resource list — the phrasing + * appears in destructive confirms, where an inconsistency reads as a different action. + */ +export function selectionLabel(count: number, firstName: string | undefined): string { + if (count === 1) return firstName ?? 'selected item' + return `${count} selected items` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx new file mode 100644 index 00000000000..827b8edc235 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type ResourceRowSelection, + type UseResourceRowSelectionOptions, + useResourceRowSelection, +} from '@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection' + +/** Trees rendered by a test, torn down in afterEach so listeners do not leak across tests. */ +const mountedRoots: Root[] = [] + +interface Harness { + getResult: () => ResourceRowSelection + /** Re-renders with new options, as a parent would when its rows change. */ + rerender: (options: UseResourceRowSelectionOptions) => void +} + +function renderSelection(initialOptions: UseResourceRowSelectionOptions): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + mountedRoots.push(root) + let result: ResourceRowSelection | undefined + + function Probe({ options }: { options: UseResourceRowSelectionOptions }) { + result = useResourceRowSelection(options) + return null + } + + const render = (options: UseResourceRowSelectionOptions) => { + act(() => { + root.render() + }) + } + + render(initialOptions) + + return { + getResult: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +function pressKey(key: string, init: KeyboardEventInit = {}) { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +const ROWS = ['a', 'b', 'c', 'd'] + +describe('useResourceRowSelection', () => { + it('adds and removes a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('b', true)) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('extends a shift-click range from the last anchor', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('a', true)) + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c']) + }) + + it('treats a shift-click with no anchor as a plain click', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds]).toEqual(['c']) + }) + + it('reports isAllSelected only once every visible row is selected', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c', 'd']) + expect(getResult().selectable.isAllSelected).toBe(true) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectable.isAllSelected).toBe(false) + }) + + it('drops rows that are no longer visible', () => { + const { getResult, rerender } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + rerender({ visibleRowIds: ['a', 'c'] }) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'c']) + }) + + it('replaceSelection collapses onto the given rows and re-anchors a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + act(() => getResult().replaceSelection(['b'])) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + // 'b' became the anchor, so a shift-click on 'd' fills the range from there. + act(() => getResult().selectable.onSelectRow('d', true, true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['b', 'c', 'd']) + }) + + it('selects every visible row on Cmd+A and clears on Escape', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + + pressKey('Escape') + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('calls onDeleteSelected for Delete only while rows are selected', () => { + const onDeleteSelected = vi.fn() + const { getResult } = renderSelection({ visibleRowIds: ROWS, onDeleteSelected }) + + pressKey('Delete') + expect(onDeleteSelected).not.toHaveBeenCalled() + + act(() => getResult().selectable.onSelectRow('a', true)) + pressKey('Delete') + expect(onDeleteSelected).toHaveBeenCalledTimes(1) + }) + + it('ignores shortcuts while blocked or while a text field has focus', () => { + const onDeleteSelected = vi.fn() + const blocked = { current: true } + const { getResult } = renderSelection({ + visibleRowIds: ROWS, + isKeyboardBlocked: () => blocked.current, + onDeleteSelected, + }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + blocked.current = false + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + input.blur() + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts new file mode 100644 index 00000000000..a4852fcb709 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts @@ -0,0 +1,210 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SelectableConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** Shared empty set so an empty selection keeps a stable identity across renders. */ +const EMPTY_ROW_IDS = new Set() + +/** Sentinel for "no shift-range anchor", so index 0 stays a usable anchor. */ +const NO_ANCHOR = -1 + +/** + * True while a text-entry surface owns the keystroke, so the list shortcuts never eat a + * character the user is typing into a rename field, a search box, or an editor. + */ +function isTypingTarget(): boolean { + const active = document.activeElement + if (!active) return false + return ( + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable + ) +} + +export interface UseResourceRowSelectionOptions { + /** + * Row ids currently rendered, in display order. Selection is pruned to this list whenever it + * changes (navigating into a folder, applying a filter) and shift-ranges walk it, so it must + * be the same array identity across renders that do not change the rows. + */ + visibleRowIds: string[] + /** + * Blocks the keyboard shortcuts while another surface owns the keystroke — a detail view open + * over the list, an inline rename in progress, a modal. Text inputs are already excluded. + */ + isKeyboardBlocked?: () => boolean + /** Bound to Delete/Backspace on a non-empty selection. Omit to leave those keys unbound. */ + onDeleteSelected?: () => void +} + +export interface ResourceRowSelection { + selectedRowIds: Set + /** Passed straight to `Resource.Table`'s `selectable` prop. */ + selectable: SelectableConfig + /** Collapses the selection to exactly these rows, e.g. a plain row click or a drag start. */ + replaceSelection: (rowIds: Iterable) => void + clearSelection: () => void +} + +/** + * Checkbox selection for a `Resource.Table` list: click, shift-click ranges, select-all, and the + * Cmd/Ctrl+A · Escape · Delete shortcuts, shared so Files, Tables, and Knowledge select + * identically rather than each re-deriving the same state machine. + * + * Selection is keyed by *row* id, not resource id, so a foldered list can hold folder rows and + * resource rows in one selection; consumers split it back out with `parseFolderedRowId`. + */ +export function useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked, + onDeleteSelected, +}: UseResourceRowSelectionOptions): ResourceRowSelection { + const [selectedRowIds, setSelectedRowIds] = useState>(() => EMPTY_ROW_IDS) + + /** Anchor for shift-click ranges — an index into `visibleRowIds`, not a row id. */ + const anchorIndexRef = useRef(NO_ANCHOR) + + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const isKeyboardBlockedRef = useRef(isKeyboardBlocked) + isKeyboardBlockedRef.current = isKeyboardBlocked + const onDeleteSelectedRef = useRef(onDeleteSelected) + onDeleteSelectedRef.current = onDeleteSelected + + const clearSelection = useCallback(() => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => (prev.size === 0 ? prev : EMPTY_ROW_IDS)) + }, []) + + const replaceSelection = useCallback((rowIds: Iterable) => { + const next = new Set(rowIds) + /** + * A single row becomes the next shift anchor; a multi-row replacement has no meaningful + * anchor, so the following shift-click starts a fresh range instead of extending from a + * row the user never clicked. + */ + let anchor = NO_ANCHOR + if (next.size === 1) { + for (const rowId of next) anchor = visibleRowIdsRef.current.indexOf(rowId) + } + anchorIndexRef.current = anchor + setSelectedRowIds(next) + }, []) + + /** + * Rows that left the list — navigating into a folder, applying a filter — are gone as far as + * selection is concerned, otherwise a bulk action would silently operate on rows the user can + * no longer see. Compared by identity because `visibleRowIds` is memoized upstream and only + * changes when the rows really change. + */ + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + /** + * Identity is only a cheap first test — it changes for reasons that are not list changes. + * Both foldered pages rebuild every row on each inline-rename keystroke (the edit value + * lives in the row memo), so a rename would otherwise clear the shift anchor mid-edit and + * the next shift-click would start a fresh range instead of extending the user's. + */ + const unchanged = + prevVisibleRowIdsRef.current.length === visibleRowIds.length && + prevVisibleRowIdsRef.current.every((rowId, index) => rowId === visibleRowIds[index]) + prevVisibleRowIdsRef.current = visibleRowIds + if (unchanged) return + anchorIndexRef.current = NO_ANCHOR + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set() + for (const rowId of prev) if (visible.has(rowId)) next.add(rowId) + return next.size === prev.size ? prev : next + }) + }, [visibleRowIds]) + + /** + * The size check short-circuits the common case (a selection smaller than the list) in O(1); + * this runs on every render of the page, including each one a drag triggers. + */ + const isAllSelected = + visibleRowIds.length > 0 && + selectedRowIds.size >= visibleRowIds.length && + visibleRowIds.every((rowId) => selectedRowIds.has(rowId)) + + const selectable = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId, checked, shiftKey) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && anchorIndexRef.current !== NO_ANCHOR && currentIndex !== NO_ANCHOR) { + const start = Math.min(anchorIndexRef.current, currentIndex) + const end = Math.max(anchorIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + anchorIndexRef.current = currentIndex + return + } + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + anchorIndexRef.current = checked ? currentIndex : NO_ANCHOR + }, + onSelectAll: (checked) => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isKeyboardBlockedRef.current?.()) return + if (isTypingTarget()) return + + const hasSelection = selectedRowIdsRef.current.size > 0 + + if ((e.key === 'Delete' || e.key === 'Backspace') && hasSelection) { + if (!onDeleteSelectedRef.current) return + e.preventDefault() + onDeleteSelectedRef.current() + return + } + + if (e.key === 'Escape' && hasSelection) { + e.preventDefault() + clearSelection() + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [clearSelection]) + + return { selectedRowIds, selectable, replaceSelection, clearSelection } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx deleted file mode 100644 index 53ebe27ba84..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Folder, - Tooltip, - Trash, -} from '@sim/emcn' -import { Download } from '@sim/emcn/icons' -import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' - -interface FilesActionBarProps { - selectedCount: number - onDownload?: () => void - onMove?: (optionValue: string) => void - moveOptions?: MoveOptionNode[] - onDelete?: () => void - isLoading?: boolean - className?: string -} - -export function FilesActionBar({ - selectedCount, - onDownload, - onMove, - moveOptions, - onDelete, - isLoading = false, - className, -}: FilesActionBarProps) { - return ( - - - {selectedCount > 0 && ( - -
- - {selectedCount} selected - -
- {onDownload && ( - - - - - Download - - )} - {onMove && moveOptions && ( - - - - - - - - Move - - - {moveOptions.length > 0 && ( - onMove(moveOptions[0].value)}> - - {moveOptions[0].label} - - )} - {moveOptions.length > 1 && } - {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} - - - )} - {onDelete && ( - - - - - Delete - - )} -
-
-
- )} -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts deleted file mode 100644 index aa19162a077..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..b29e169914b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1,6 +1,6 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, ChipCombobox, @@ -51,15 +51,18 @@ import type { ResourceAction, ResourceColumn, ResourceRow, - RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -67,14 +70,19 @@ import type { } from '@/app/workspace/[workspaceId]/components/folders' import { breadcrumbFolderChain, + buildDescendantIndex, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, + folderRowId, + parseFolderedRowId, parseMoveOptionValue, - ROOT_MOVE_OPTION_VALUE, sortResources, + splitFolderedRowIds, + useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' -import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -141,6 +149,12 @@ type FileListEntry = const logger = createLogger('Files') +/** + * This list's private drag MIME, so a drag started on another list is never mistaken for one of + * these rows. + */ +const FILE_ROW_DRAG_MIME = 'application/x-sim-workspace-file-rows' + const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file const FOLDER_ICON = @@ -192,14 +206,6 @@ const MIME_TYPE_LABELS: Record = { const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] -const fileRowId = (id: string) => `file:${id}` -const folderRowId = (id: string) => `folder:${id}` -const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => { - if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } - if (rowId.startsWith('file:')) return { kind: 'file', id: rowId.slice('file:'.length) } - return { kind: 'file', id: rowId } -} - const hasExternalFiles = (dataTransfer: DataTransfer): boolean => dataTransfer.types.includes('Files') @@ -296,17 +302,39 @@ export function Files() { const justCreatedFileIdRef = useRef(null) const filesRef = useRef(files) filesRef.current = files - const foldersRef = useRef(folders) - foldersRef.current = folders + /** + * Indexed once. The drag hook resolves each dragged row's placement inside `dragover`, which + * fires continuously — a linear scan there is O(selection x resources) per event. + */ + const fileById = useMemo(() => { + const byId = new Map() + for (const file of files) byId.set(file.id, file) + return byId + }, [files]) + const fileByIdRef = useRef(fileById) + fileByIdRef.current = fileById - const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0, currentPercent: 0, }) + /** An upload batch is in flight exactly while a total is set — matches the Tables page. */ + const uploading = uploadProgress.total > 0 const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) + /** + * Takes down the "Drop to upload" overlay. + * + * Every path that consumes an OS file drag has to call this, including the one that never + * reaches the page-level handler: a drop on a folder row is handled by the drag hook, which + * stops propagation, so `handleDrop` below never runs and the counter it would have zeroed + * keeps the overlay on screen over the finished upload. + */ + const dismissUploadOverlay = useCallback(() => { + dragCounterRef.current = 0 + setIsDraggingOver(false) + }, []) const [ { search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter }, setFileFilters, @@ -347,9 +375,6 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') - const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) - const [activeDropTargetId, setActiveDropTargetId] = useState(null) - const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -362,9 +387,6 @@ export function Files() { const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const contextMenuItemRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) - const draggedRowIdsRef = useRef([]) - const dragGhostRef = useRef(null) const [deleteTarget, setDeleteTarget] = useState<{ fileIds: string[] folderIds: string[] @@ -373,7 +395,7 @@ export function Files() { const listRename = useInlineRename({ onSave: (rowId, name) => { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { return updateFolder.mutateAsync({ workspaceId, folderId: parsed.id, updates: { name } }) } @@ -440,6 +462,8 @@ export function Files() { ) : null const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById const folderSizeMap = useMemo(() => { const directSize = new Map() @@ -628,7 +652,7 @@ export function Files() { const { file } = item const Icon = getDocumentIcon(file.type || '', file.name) return { - id: fileRowId(file.id), + id: file.id, cells: { name: { icon: , @@ -676,135 +700,23 @@ export function Files() { const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const prevVisibleRowIdsRef = useRef(visibleRowIds) - useEffect(() => { - if (prevVisibleRowIdsRef.current === visibleRowIds) return - prevVisibleRowIdsRef.current = visibleRowIds - lastSelectedIndexRef.current = -1 - const visible = new Set(visibleRowIds) - setSelectedRowIds((prev) => { - if (prev.size === 0) return prev - const next = new Set(Array.from(prev).filter((id) => visible.has(id))) - return next.size === prev.size ? prev : next - }) - }, [visibleRowIds]) - - const isAllSelected = - visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) - const { selectedFileIds, selectedFolderIds } = useMemo(() => { - const fileIds: string[] = [] - const folderIds: string[] = [] - for (const rowId of selectedRowIds) { - const item = parseRowId(rowId) - if (item.kind === 'file') fileIds.push(item.id) - else folderIds.push(item.id) - } - return { selectedFileIds: fileIds, selectedFolderIds: folderIds } - }, [selectedRowIds]) + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => Boolean(fileIdFromRoute) || listRename.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) - const selectableConfig = useMemo( - () => ({ - selectedIds: selectedRowIds, - isAllSelected, - onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { - const currentIndex = visibleRowIds.indexOf(rowId) - if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) - return next - }) - lastSelectedIndexRef.current = currentIndex - } else { - setSelectedRowIds((prev) => { - const next = new Set(prev) - if (checked) next.add(rowId) - else next.delete(rowId) - return next - }) - if (checked) lastSelectedIndexRef.current = currentIndex - else lastSelectedIndexRef.current = -1 - } - }, - onSelectAll: (checked: boolean) => { - lastSelectedIndexRef.current = -1 - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (const rowId of visibleRowIds) { - if (checked) next.add(rowId) - else next.delete(rowId) - } - return next - }) - }, - disabled: false, - }), - [selectedRowIds, isAllSelected, visibleRowIds] + const { folderIds: selectedFolderIds, resourceIds: selectedFileIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] ) - const descendantFolderIdsByFolderId = useMemo(() => { - const childrenByParent = new Map() - for (const folder of folders) { - if (!folder.parentId) continue - const children = childrenByParent.get(folder.parentId) ?? [] - children.push(folder.id) - childrenByParent.set(folder.parentId, children) - } - - const result = new Map>() - const collect = (folderId: string, seen = new Set()): Set => { - const cached = result.get(folderId) - if (cached) return cached - if (seen.has(folderId)) return new Set() - - const nextSeen = new Set(seen) - nextSeen.add(folderId) - const descendants = new Set() - for (const childId of childrenByParent.get(folderId) ?? []) { - if (nextSeen.has(childId)) continue - descendants.add(childId) - for (const nestedId of collect(childId, nextSeen)) { - descendants.add(nestedId) - } - } - result.set(folderId, descendants) - return descendants - } - - for (const folder of folders) { - collect(folder.id) - } - return result - }, [folders]) - - const isInvalidDropTarget = useCallback( - (targetRowId: string, sourceRowIds: string[]) => { - const target = parseRowId(targetRowId) - if (target.kind !== 'folder') return true - - for (const sourceRowId of sourceRowIds) { - const source = parseRowId(sourceRowId) - if (source.kind !== 'folder') continue - if (source.id === target.id) return true - if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true - } - - // Reject drop if every dragged item is already a direct child of the target - const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { - const source = parseRowId(sourceRowId) - if (source.kind === 'file') { - return filesRef.current.find((f) => f.id === source.id)?.folderId === target.id - } - return (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === target.id - }) - if (allAlreadyInTarget) return true - - return false - }, - [descendantFolderIdsByFolderId] - ) + const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) const uploadFiles = useCallback( async (filesToUpload: File[], targetFolderId = currentFolderId) => { @@ -841,7 +753,6 @@ export function Files() { if (allowedFiles.length === 0) return try { - setUploading(true) setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) for (let i = 0; i < allowedFiles.length; i++) { @@ -872,159 +783,51 @@ export function Files() { } catch (err) { logger.error('Error uploading file:', err) } finally { - setUploading(false) setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, [workspaceId, canEdit, currentFolderId, notifyLimit] ) - const rowDragDropConfig = useMemo( - () => ({ - activeDropTargetId, - draggedRowIds, - isAnyDragActive: draggedRowIds.size > 0, - isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId, - isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder', - onDragStart: (e: DragEvent, rowId) => { - if (!canEdit || listRename.editingId === rowId) { - e.preventDefault() - return - } - - const sourceRowIds = selectedRowIds.has(rowId) - ? visibleRowIds.filter((visibleRowId) => selectedRowIds.has(visibleRowId)) - : [rowId] - - draggedRowIdsRef.current = sourceRowIds - setDraggedRowIds(new Set(sourceRowIds)) - if (!selectedRowIds.has(rowId)) { - setSelectedRowIds(new Set([rowId])) - } - - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData( - 'application/x-sim-workspace-file-rows', - JSON.stringify(sourceRowIds) - ) - e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) - - const count = sourceRowIds.length - const firstParsed = parseRowId(sourceRowIds[0]) - const firstName = - firstParsed.kind === 'file' - ? filesRef.current.find((f) => f.id === firstParsed.id)?.name - : foldersRef.current.find((f) => f.id === firstParsed.id)?.name - const ghostLabel = - count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') - const ghost = document.createElement('div') - ghost.style.cssText = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = ghostLabel - ghost.appendChild(text) - document.body.appendChild(ghost) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost - }, - onDragOver: (e: DragEvent, rowId) => { - const sourceRowIds = draggedRowIdsRef.current - const isExternalFileDrag = hasExternalFiles(e.dataTransfer) - if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return - - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' - setActiveDropTargetId(rowId) - }, - onDragLeave: (e: DragEvent, rowId) => { - const relatedTarget = e.relatedTarget - if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return - setActiveDropTargetId((current) => (current === rowId ? null : current)) - }, - onDrop: (e: DragEvent, rowId) => { - e.preventDefault() - e.stopPropagation() - dragCounterRef.current = 0 - setIsDraggingOver(false) - setActiveDropTargetId(null) - const target = parseRowId(rowId) - if (target.kind !== 'folder') return - - const droppedFiles = Array.from(e.dataTransfer.files ?? []) - if (droppedFiles.length > 0) { - void uploadFiles(droppedFiles, target.id) - return - } - - let sourceRowIds = draggedRowIdsRef.current - const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') - if (rawSource) { - try { - const parsedSource = JSON.parse(rawSource) - if (Array.isArray(parsedSource)) { - sourceRowIds = parsedSource.filter( - (source): source is string => typeof source === 'string' && source.length > 0 - ) - } - } catch { - sourceRowIds = draggedRowIdsRef.current - } - } - - if (isInvalidDropTarget(rowId, sourceRowIds)) return - - const fileIds = sourceRowIds - .map(parseRowId) - .filter((source) => source.kind === 'file') - .map((source) => source.id) - const folderIds = sourceRowIds - .map(parseRowId) - .filter((source) => source.kind === 'folder') - .map((source) => source.id) - - if (fileIds.length === 0 && folderIds.length === 0) return - - void moveItems - .mutateAsync({ - workspaceId, - fileIds, - folderIds, - targetFolderId: target.id, - }) - .then(() => { - setSelectedRowIds(new Set()) - }) - .catch((error) => { - logger.error('Failed to move items via drag and drop:', error) - }) - }, - onDragEnd: () => { - if (dragGhostRef.current) { - dragGhostRef.current.remove() - dragGhostRef.current = null - } - dragCounterRef.current = 0 - draggedRowIdsRef.current = [] - setDraggedRowIds(new Set()) - setIsDraggingOver(false) - setActiveDropTargetId(null) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: FILE_ROW_DRAG_MIME, + canEdit, + editingRowId: listRename.editingId, + descendantsByFolderId: descendantFolderIdsByFolderId, + getFolderParentId: (folderId) => folderByIdRef.current.get(folderId)?.parentId ?? null, + getResourceFolderId: (fileId) => fileByIdRef.current.get(fileId)?.folderId ?? null, + getRowLabel: (rowId) => { + const parsed = parseFolderedRowId(rowId) + return parsed.kind === 'folder' + ? (folderByIdRef.current.get(parsed.id)?.name ?? 'Folder') + : (fileByIdRef.current.get(parsed.id)?.name ?? 'File') + }, + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => { + void moveItems + .mutateAsync({ workspaceId, fileIds: resourceIds, folderIds, targetFolderId }) + .then(() => clearSelection()) + .catch((error) => logger.error('Failed to move items:', error)) + }, + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: (folderId, options) => { + void setFilesParams({ folderId, new: null }, options) + }, + currentFolderId, + /** + * The one thing this list does that the others do not. Folder rows still highlight and + * spring open for an OS file drag — filing an upload into a nested folder is the same + * gesture — while the body and breadcrumb decline so the page-level "Drop to upload" + * overlay owns those regions instead of competing with them. + */ + externalDrop: { + matches: hasExternalFiles, + onDropIntoFolder: (dataTransfer, targetFolderId) => { + dismissUploadOverlay() + const dropped = Array.from(dataTransfer.files ?? []) + if (dropped.length > 0) void uploadFiles(dropped, targetFolderId) }, - }), - [ - activeDropTargetId, - draggedRowIds, - canEdit, - listRename.editingId, - selectedRowIds, - visibleRowIds, - isInvalidDropTarget, - uploadFiles, - workspaceId, - ] - ) + }, + }) const handleFileChange = async (e: React.ChangeEvent) => { const list = e.target.files @@ -1055,8 +858,13 @@ export function Files() { const handleDrop = async (e: React.DragEvent) => { if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() - dragCounterRef.current = 0 - setIsDraggingOver(false) + /** + * The upload lands in the folder currently open, so the view must stay there. Without this + * the window-level teardown treats the drag as unconsumed and returns to the folder it + * began in — pulling the user out of the folder they just spring-opened to receive it. + */ + rowDragDropConfig.externalDropHandled() + dismissUploadOverlay() const dropped = Array.from(e.dataTransfer.files) if (dropped.length > 0) await uploadFiles(dropped) } @@ -1106,7 +914,7 @@ export function Files() { } setShowDeleteConfirm(false) setDeleteTarget(null) - setSelectedRowIds(new Set()) + clearSelection() if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') @@ -1179,12 +987,11 @@ export function Files() { setDeleteTarget({ fileIds: selectedFileIds, folderIds: selectedFolderIds, - name: - selectedFileIds.length + selectedFolderIds.length === 1 - ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? - folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? - 'selected item') - : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + name: selectionLabel( + selectedFileIds.length + selectedFolderIds.length, + files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name + ), }) setShowDeleteConfirm(true) }, [selectedFileIds, selectedFolderIds, files, folders]) @@ -1352,7 +1159,7 @@ export function Files() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) const item = parsed.kind === 'folder' ? folders.find((folder) => folder.id === parsed.id) @@ -1363,12 +1170,11 @@ export function Files() { ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } if (!selectedRowIds.has(rowId)) { - lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } openContextMenu(e) }, - [folders, openContextMenu, selectedRowIds, visibleRowIds] + [folders, openContextMenu, selectedRowIds] ) const handleContextMenuOpen = useCallback(() => { @@ -1390,7 +1196,7 @@ export function Files() { const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current if (!item) return - const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + const rowId = item.kind === 'file' ? item.file.id : folderRowId(item.folder.id) if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { void handleBulkDownload() closeContextMenu() @@ -1408,7 +1214,7 @@ export function Files() { const handleContextMenuRename = useCallback(() => { const item = contextMenuItemRef.current - if (item?.kind === 'file') listRename.startRename(fileRowId(item.file.id), item.file.name) + if (item?.kind === 'file') listRename.startRename(item.file.id, item.file.name) if (item?.kind === 'folder') listRename.startRename(folderRowId(item.folder.id), item.folder.name) closeContextMenu() @@ -1423,7 +1229,7 @@ export function Files() { const handleContextMenuDelete = useCallback(() => { const item = contextMenuItemRef.current if (!item) return - const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + const rowId = item.kind === 'file' ? item.file.id : folderRowId(item.folder.id) if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { handleBulkDelete() closeContextMenu() @@ -1459,7 +1265,7 @@ export function Files() { folderIds: selectedFolderIds, targetFolderId, }) - setSelectedRowIds(new Set()) + clearSelection() closeContextMenu() } catch (error) { logger.error('Failed to move items:', error) @@ -1532,49 +1338,6 @@ export function Files() { return () => window.removeEventListener('keydown', handleKeyDown) }, [handleSave]) - const selectedRowIdsRef = useRef(selectedRowIds) - selectedRowIdsRef.current = selectedRowIds - const visibleRowIdsRef = useRef(visibleRowIds) - visibleRowIdsRef.current = visibleRowIds - const listRenameActiveRef = useRef(listRename.editingId) - listRenameActiveRef.current = listRename.editingId - const handleBulkDeleteRef = useRef(handleBulkDelete) - handleBulkDeleteRef.current = handleBulkDelete - - useEffect(() => { - const handleListKeyDown = (e: KeyboardEvent) => { - if (fileIdFromRouteRef.current) return - const active = document.activeElement - if ( - active && - (active.tagName === 'INPUT' || - active.tagName === 'TEXTAREA' || - (active as HTMLElement).isContentEditable) - ) - return - if (listRenameActiveRef.current) return - - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - handleBulkDeleteRef.current() - return - } - - if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - setSelectedRowIds(new Set()) - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { - e.preventDefault() - setSelectedRowIds(new Set(visibleRowIdsRef.current)) - } - } - window.addEventListener('keydown', handleListKeyDown) - return () => window.removeEventListener('keydown', handleListKeyDown) - }, []) - const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -1662,7 +1425,7 @@ export function Files() { const handleRowClick = useCallback( (rowId: string) => { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { void setFilesParams({ folderId: parsed.id, new: null }) return @@ -1692,21 +1455,21 @@ export function Files() { { id: 'file-delete', handler: () => handleDeleteSelected() }, ]) - const searchConfig: SearchConfig = { - value: urlSearchTerm, - onChange: setSearchTerm, - onClearAll: () => setSearchTerm(''), - placeholder: 'Search files...', - } + const searchConfig: SearchConfig = useMemo( + () => ({ + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), + placeholder: 'Search files...', + }), + [urlSearchTerm, setSearchTerm] + ) - const uploadButtonLabel = - uploading && uploadProgress.total > 0 - ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 - ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` - : `${uploadProgress.completed}/${uploadProgress.total}` - : uploading - ? 'Uploading...' - : 'Upload' + const uploadButtonLabel = uploading + ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 + ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` + : `${uploadProgress.completed}/${uploadProgress.total}` + : 'Upload' const headerActionsConfig = useMemo( () => [ @@ -1827,45 +1590,21 @@ export function Files() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) - const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { - // Index children by parent ONCE (the same pattern used for folder sizes + descendant maps above), - // so building the tree is O(N) instead of a full `folders.filter` scan at every node (O(N²)). - const childrenByParent = new Map() - for (const f of folders) { - const key = f.parentId ?? null - const arr = childrenByParent.get(key) - if (arr) arr.push(f) - else childrenByParent.set(key, [f]) - } - const buildSubtree = (parentId: string | null): MoveOptionNode[] => - (childrenByParent.get(parentId) ?? []) - .filter((f) => { - if (selectedFolderIds.includes(f.id)) return false - return selectedFolderIds.every( - (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) - ) - }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) - - return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)] - }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const contextMenuMoveOptions = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Files', + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIdsByFolderId, + }), + [folders, selectedFolderIds, descendantFolderIdsByFolderId] + ) const sortConfig: SortConfig = useMemo( () => ({ @@ -1921,7 +1660,7 @@ export function Files() { return (
- File Type + File Type
- Size + Size {memberOptions.length > 0 && (
- Uploaded By + Uploaded By ({ content: filterContent }), [filterContent]) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (typeFilter.length > 0) { @@ -2124,13 +1866,14 @@ export function Files() { icon={FILES_HEADER.rootIcon} title={FILES_HEADER.rootLabel} breadcrumbs={listBreadcrumbs} + breadcrumbDrop={rowDragDropConfig.breadcrumb} actions={headerActionsConfig} /> - {isDraggingOver ? ( -
+
-
-

Drop to upload

-

- Release files here to add them to this workspace -

-
+

Drop to upload

) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..449e1dbe83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -23,7 +23,12 @@ import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' -import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const logger = createLogger('ConnectSlackBotModal') @@ -31,11 +36,16 @@ const DEFAULT_APP_NAME = 'Sim Bot' const DONE_STEP = 4 /** Every capability is granted by default; trimming is an opt-in dropdown. */ -const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id)) +const CUSTOM_BOT_CAPABILITIES = [ + ...SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +] as const + +const ALL_CAPABILITIES = new Set(CUSTOM_BOT_CAPABILITIES.map((capability) => capability.id)) -const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({ - value: c.id, - label: c.label, +const CAPABILITY_OPTIONS: ChipDropdownOption[] = CUSTOM_BOT_CAPABILITIES.map((capability) => ({ + value: capability.id, + label: capability.label, })) interface ConnectSlackBotModalProps { @@ -118,10 +128,14 @@ export function ConnectSlackBotModal({ ) const manifestJson = useMemo(() => { + const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id) + ? getSlackManagedUserAuthorizationManifestConfig(getBaseUrl()) + : undefined const manifest = buildSlackManifest(selected, { appName: appName.trim() || DEFAULT_APP_NAME, webhookUrl: requestUrl, description: appDescription, + ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) return JSON.stringify(manifest, null, 2) }, [selected, appName, appDescription, requestUrl]) @@ -269,7 +283,7 @@ function StepConfigure({ capabilityIds, onCapabilityIdsChange, }: StepConfigureProps) { - const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length + const allSelected = capabilityIds.length === CUSTOM_BOT_CAPABILITIES.length return (
@@ -310,7 +324,7 @@ function StepConfigure({ {allSelected && (

Full access — the bot can read and send messages, react, upload files, and chat as an AI - assistant. + assistant, and people can authorize it through Credential Groups.

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 2b1ae99f392..f4da0081688 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -46,7 +46,6 @@ import { } from '@/hooks/queries/credentials' import { useConnectOAuthService, - useDisconnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth/oauth-connections' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -74,7 +73,6 @@ export function ConnectedCredentialDetail({ const { data: oauthConnections = [] } = useOAuthConnections() const connectOAuthService = useConnectOAuthService() - const disconnectOAuthService = useDisconnectOAuthService() const createDraft = useCreateCredentialDraft() const deleteCredential = useDeleteWorkspaceCredential() @@ -113,7 +111,7 @@ export function ConnectedCredentialDetail({ const handleReconnectOAuth = async () => { if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return try { - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId: credential.providerId, displayName: credential.displayName, @@ -137,6 +135,7 @@ export function ConnectedCredentialDetail({ await connectOAuthService.mutateAsync({ providerId: credential.providerId, callbackURL: window.location.href, + draftId: draft.draftId, }) } catch (error: unknown) { toast.error("Couldn't start reconnect", { @@ -146,30 +145,15 @@ export function ConnectedCredentialDetail({ } } + /** + * Every credential type disconnects through the workspace-scoped credential + * delete, which authorizes against credential admin — explicit members and + * derived workspace admins alike. + */ const handleConfirmDelete = async () => { if (!credential) return try { - if (credential.type === 'service_account') { - await deleteCredential.mutateAsync(credential.id) - } else { - if (!credential.accountId || !credential.providerId) { - toast.error("Can't disconnect", { - description: 'Missing account information. Try reconnecting this credential first.', - }) - return - } - await disconnectOAuthService.mutateAsync({ - provider: credential.providerId.split('-')[0] || credential.providerId, - providerId: credential.providerId, - serviceId: credential.providerId, - accountId: credential.accountId, - }) - window.dispatchEvent( - new CustomEvent('oauth-credentials-updated', { - detail: { providerId: credential.providerId, workspaceId }, - }) - ) - } + await deleteCredential.mutateAsync(credential.id) setShowDeleteConfirmDialog(false) router.push(integrationsHref) } catch (error) { @@ -207,7 +191,7 @@ export function ConnectedCredentialDetail({ setShowDeleteConfirmDialog(true)} - disabled={disconnectOAuthService.isPending || deleteCredential.isPending} + disabled={deleteCredential.isPending} > Disconnect @@ -303,7 +287,7 @@ export function ConnectedCredentialDetail({ confirm={{ label: 'Disconnect', onClick: handleConfirmDelete, - pending: disconnectOAuthService.isPending || deleteCredential.isPending, + pending: deleteCredential.isPending, pendingLabel: 'Disconnecting...', }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index faa99423d44..e5fa53f5108 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -14,8 +14,8 @@ import { ChipModalHeader, handleKeyboardActivation, Label, - Trash, } from '@sim/emcn' +import { Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { @@ -378,11 +378,7 @@ export function DocumentTagsModal({ return ( - handleClose(false)}> -
- Document Tags -
-
+ handleClose(false)}>Document Tags diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 974a0896848..5c9e0e4d99d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -1,8 +1,17 @@ 'use client' import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' -import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@sim/emcn' -import { ChevronDown, ChevronUp, Database, FileText, Pencil, TagIcon } from '@sim/emcn/icons' +import { Badge, ChipCombobox, ChipConfirmModal, chipContentLabelClass, cn } from '@sim/emcn' +import { + ChevronDown, + ChevronUp, + Database, + FileText, + Pencil, + Plus, + TagIcon, + Trash, +} from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import { useParams, useRouter } from 'next/navigation' @@ -204,7 +213,6 @@ export function Document({ chunks: initialChunks, currentPage: initialPage, totalPages: initialTotalPages, - goToPage: initialGoToPage, error: initialError, updateChunk: initialUpdateChunk, } = useDocumentChunks( @@ -292,26 +300,22 @@ export function Document({ const totalPagesRef = useRef(totalPages) totalPagesRef.current = totalPages - const goToPage = useCallback( - async (page: number) => { - await setDocumentParams({ page }) - - if (showingSearch) { - return - } - return initialGoToPage(page) - }, - [showingSearch, initialGoToPage, setDocumentParams] - ) + const goToPage = useCallback((page: number) => setDocumentParams({ page }), [setDocumentParams]) const updateChunk = showingSearch ? (_id: string, _updates: Record) => {} : initialUpdateChunk const [chunkToDelete, setChunkToDelete] = useState(null) - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false) - const [contextMenuChunk, setContextMenuChunk] = useState(null) + const [contextMenuChunkId, setContextMenuChunkId] = useState(null) + /** + * The id, not the row: the chunk list polls while a document processes, and a menu that + * captured the row on open would keep offering "Enable" for a chunk already enabled. + */ + const contextMenuChunk = contextMenuChunkId + ? (displayChunks.find((chunk) => chunk.id === contextMenuChunkId) ?? null) + : null const { mutate: updateChunkMutation } = useUpdateChunk() const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument() @@ -351,15 +355,10 @@ export function Document({ const isInEditorView = selectedChunkId !== null || isCreatingNewChunk - const selectedChunk = useMemo( - () => (selectedChunkId ? (displayChunks.find((c) => c.id === selectedChunkId) ?? null) : null), - [selectedChunkId, displayChunks] - ) - - const currentChunkIndex = useMemo( - () => (selectedChunk ? displayChunks.findIndex((c) => c.id === selectedChunk.id) : -1), - [selectedChunk, displayChunks] - ) + const currentChunkIndex = selectedChunkId + ? displayChunks.findIndex((chunk) => chunk.id === selectedChunkId) + : -1 + const selectedChunk = currentChunkIndex >= 0 ? displayChunks[currentChunkIndex] : null const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1 const canNavigateNext = currentChunkIndex < displayChunks.length - 1 || currentPage < totalPages @@ -402,14 +401,14 @@ export function Document({ } }, [isDirty, isCreatingNewChunk]) - const handleUnsavedChangesOpenChange = useCallback((open: boolean) => { + const handleUnsavedChangesOpenChange = (open: boolean) => { if (!open) { setShowUnsavedChangesAlert(false) setPendingAction(null) } - }, []) + } - const handleDiscardChanges = useCallback(() => { + const handleDiscardChanges = () => { setShowUnsavedChangesAlert(false) const action = pendingAction setPendingAction(null) @@ -419,7 +418,7 @@ export function Document({ } else { closeEditor() } - }, [pendingAction, closeEditor]) + } const handleSaveEvent = useEffectEvent(handleSave) @@ -646,7 +645,6 @@ export function Document({ if (found) { setSelectedChunkId(chunkId) } else if (!navigatedToNewPage && totalPagesRef.current > totalPages) { - // A new page was created — navigate to it navigatedToNewPage = true retries = 0 void goToPage(totalPagesRef.current) @@ -681,10 +679,8 @@ export function Document({ } : undefined - const enabledDisplayLabel = useMemo(() => { - if (enabledFilter.length === 0) return 'All' - return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' - }, [enabledFilter]) + const enabledDisplayLabel = + enabledFilter.length === 0 ? 'All' : enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' const filterContent = useMemo( () => ( @@ -724,7 +720,7 @@ export function Document({ )}
), - [enabledFilter, enabledDisplayLabel, setEnabledFilter] + [enabledFilter, setEnabledFilter] ) const filterTags: FilterTag[] = useMemo( @@ -746,31 +742,22 @@ export function Document({ [setSelectedChunkId] ) - const handleToggleEnabled = useCallback( - (chunkId: string) => { - const chunk = displayChunks.find((c) => c.id === chunkId) - if (!chunk) return + const handleToggleEnabled = (chunkId: string) => { + const chunk = displayChunks.find((c) => c.id === chunkId) + if (!chunk) return - const newEnabled = !chunk.enabled - updateChunk(chunkId, { enabled: newEnabled }) - updateChunkMutation( - { knowledgeBaseId, documentId, chunkId, enabled: newEnabled }, - { onError: () => updateChunk(chunkId, { enabled: chunk.enabled }) } - ) - }, - [displayChunks, knowledgeBaseId, documentId, updateChunk] - ) + const newEnabled = !chunk.enabled + updateChunk(chunkId, { enabled: newEnabled }) + updateChunkMutation( + { knowledgeBaseId, documentId, chunkId, enabled: newEnabled }, + { onError: () => updateChunk(chunkId, { enabled: chunk.enabled }) } + ) + } - const handleDeleteChunk = useCallback( - (chunkId: string) => { - const chunk = displayChunks.find((c) => c.id === chunkId) - if (chunk) { - setChunkToDelete(chunk) - setIsDeleteModalOpen(true) - } - }, - [displayChunks] - ) + const handleDeleteChunk = (chunkId: string) => { + const chunk = displayChunks.find((c) => c.id === chunkId) + if (chunk) setChunkToDelete(chunk) + } const handleCloseDeleteModal = () => { if (chunkToDelete) { @@ -780,7 +767,6 @@ export function Document({ return newSet }) } - setIsDeleteModalOpen(false) setChunkToDelete(null) } @@ -863,17 +849,14 @@ export function Document({ performBulkChunkOperation('delete', chunksToDelete) } - const [enabledCount, disabledCount] = useMemo(() => { - let enabled = 0 - let disabled = 0 - for (const chunk of displayChunks) { - if (selectedChunks.has(chunk.id)) { - if (chunk.enabled) enabled++ - else disabled++ - } + let enabledCount = 0 + let disabledCount = 0 + for (const chunk of displayChunks) { + if (selectedChunks.has(chunk.id)) { + if (chunk.enabled) enabledCount++ + else disabledCount++ } - return [enabled, disabled] - }, [displayChunks, selectedChunks]) + } const isAllSelected = displayChunks.length > 0 && selectedChunks.size === displayChunks.length @@ -890,7 +873,7 @@ export function Document({ } } - setContextMenuChunk(chunk) + setContextMenuChunkId(chunk.id) baseHandleContextMenu(e) }, [ @@ -902,18 +885,15 @@ export function Document({ ] ) - const handleEmptyContextMenu = useCallback( - (e: React.MouseEvent) => { - setContextMenuChunk(null) - baseHandleContextMenu(e) - }, - [baseHandleContextMenu] - ) + const handleEmptyContextMenu = (e: React.MouseEvent) => { + setContextMenuChunkId(null) + baseHandleContextMenu(e) + } - const handleContextMenuClose = useCallback(() => { + const handleContextMenuClose = () => { closeContextMenu() - setContextMenuChunk(null) - }, [closeContextMenu]) + setContextMenuChunkId(null) + } const selectableConfig: SelectableConfig | undefined = isCompleted ? { @@ -955,7 +935,17 @@ export function Document({ [activeSort, onSortColumn, onClearSort, goToPage] ) + const hasDocumentData = documentData !== null + const processingStatus = documentData?.processingStatus + const chunkRows: ResourceRow[] = useMemo(() => { + /** + * No document yet is "not known", not "not ready". Falling through to the status row + * flashed `Document not ready` on every open, for the frame between mount and the + * document query resolving — a claim about a document nothing had read yet. + */ + if (!hasDocumentData) return [] + if (!isCompleted) { return [ { @@ -966,12 +956,10 @@ export function Document({
- {documentData?.processingStatus === 'pending' && - 'Document processing pending...'} - {documentData?.processingStatus === 'processing' && - 'Document processing in progress...'} - {documentData?.processingStatus === 'failed' && 'Document processing failed'} - {!documentData?.processingStatus && 'Document not ready'} + {processingStatus === 'pending' && 'Document processing pending...'} + {processingStatus === 'processing' && 'Document processing in progress...'} + {processingStatus === 'failed' && 'Document processing failed'} + {!processingStatus && 'Document not ready'}
), @@ -992,16 +980,14 @@ export function Document({ cells: { content: { content: ( - + ), }, index: { content: ( - - {chunk.chunkIndex} - + {chunk.chunkIndex} ), }, tokens: { @@ -1017,7 +1003,7 @@ export function Document({ }, } }) - }, [isCompleted, documentData?.processingStatus, displayChunks, searchQuery]) + }, [isCompleted, hasDocumentData, processingStatus, displayChunks, searchQuery]) const saveLabel = saveStatus === 'saving' @@ -1232,7 +1218,7 @@ export function Document({ chunk={chunkToDelete} knowledgeBaseId={knowledgeBaseId} documentId={documentId} - isOpen={isDeleteModalOpen} + isOpen={chunkToDelete !== null} onClose={handleCloseDeleteModal} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx index ed67b33a791..63369f7a3d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx @@ -1,7 +1,6 @@ 'use client' -import { Plus } from '@sim/emcn' -import { Database, FileText } from '@sim/emcn/icons' +import { Database, FileText, Plus } from '@sim/emcn/icons' import { noop } from '@sim/utils/helpers' import { type BreadcrumbItem, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..a01bb376e57 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -20,12 +20,20 @@ import { cn, FloatingTooltip, isTextClipped, - Loader, Tooltip, - Trash, useFloatingTooltip, } from '@sim/emcn' -import { CircleAlert, Database, DatabaseX, Pencil, Plus, TagIcon, X } from '@sim/emcn/icons' +import { + CircleAlert, + Database, + DatabaseX, + Loader, + Pencil, + Plus, + TagIcon, + Trash, + X, +} from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -50,7 +58,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FILTER_SECTION_LABEL_CLASS, + FloatingOverflowText, + Resource, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -73,19 +85,13 @@ import { documentFiltersParsers, documentFiltersUrlKeys, kbDocumentSortParams, - pageParam, - pageUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { - useKnowledgeBase, - useKnowledgeBaseDocuments, - useKnowledgeBasesList, -} from '@/hooks/kb/use-knowledge' +import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/kb/use-knowledge' import { type TagDefinition, useKnowledgeBaseTagDefinitions, @@ -125,8 +131,6 @@ const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'disabled', label: 'Disabled' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - interface KnowledgeBaseProps { id: string knowledgeBaseName?: string @@ -278,14 +282,12 @@ export function KnowledgeBase({ }, [id, passedKnowledgeBaseName, posthog]) useOAuthReturnForKBConnectors(id) - const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) const userPermissions = useUserPermissionsContext() const { mutate: updateDocumentMutation, mutateAsync: updateDocumentAsync } = useUpdateDocument() const { mutate: deleteDocumentMutation } = useDeleteDocument() - const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } = - useDeleteKnowledgeBase(workspaceId) - const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) + const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } = useDeleteKnowledgeBase() + const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase() const kbRename = useInlineRename({ onSave: (kbId, name) => @@ -334,14 +336,13 @@ export function KnowledgeBase({ const [documentToDelete, setDocumentToDelete] = useState(null) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [showConnectorsModal, setShowConnectorsModal] = useState(false) - const [currentPage, setCurrentPage] = useQueryState(pageParam.key, { - ...pageParam.parser, - ...pageUrlKeys, - }) + const [{ q: searchQuery, enabled: enabledFilter, page: currentPage }, setDocumentFilters] = + useQueryStates(documentFiltersParsers, documentFiltersUrlKeys) - const [{ q: searchQuery, enabled: enabledFilter }, setDocumentFilters] = useQueryStates( - documentFiltersParsers, - documentFiltersUrlKeys + /** Page 1 is the group's default, so it strips from the URL rather than lingering as `?page=1`. */ + const setCurrentPage = useCallback( + (page: number) => void setDocumentFilters({ page }), + [setDocumentFilters] ) /** @@ -350,8 +351,7 @@ export function KnowledgeBase({ * doesn't refetch on every keystroke. Changing the search resets pagination. */ const handleSearchChange = useDebouncedSearchSetter((value, options) => { - setDocumentFilters({ q: value }, options) - setCurrentPage(1) + void setDocumentFilters({ q: value, page: 1 }, options) }) const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) /** Raw URL value drives the input; matching/highlighting always sees it trimmed. */ @@ -367,13 +367,12 @@ export function KnowledgeBase({ const setEnabledFilter = useCallback( (value: 'all' | 'enabled' | 'disabled') => { - setDocumentFilters({ enabled: value }) - setCurrentPage(1) + void setDocumentFilters({ enabled: value, page: 1 }) }, - [setDocumentFilters, setCurrentPage] + [setDocumentFilters] ) - const [contextMenuDocument, setContextMenuDocument] = useState(null) + const [contextMenuDocumentId, setContextMenuDocumentId] = useState(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) @@ -438,6 +437,15 @@ export function KnowledgeBase({ const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id) + /** + * The id, not the row: the document list polls every few seconds while anything is + * processing, so a menu holding the row it opened on would offer actions against a status + * that has since moved on. + */ + const contextMenuDocument = contextMenuDocumentId + ? (documents.find((doc) => doc.id === contextMenuDocumentId) ?? null) + : null + const prevHadSyncingRef = useRef(false) useEffect(() => { if (prevHadSyncingRef.current && !hasSyncingConnectors) { @@ -697,7 +705,6 @@ export function KnowledgeBase({ { knowledgeBaseId: id }, { onSuccess: () => { - removeKnowledgeBase(id) router.push(`/workspace/${workspaceId}/knowledge`) }, } @@ -883,24 +890,21 @@ export function KnowledgeBase({ setSelectedDocuments(new Set([doc.id])) } - setContextMenuDocument(doc) + setContextMenuDocumentId(doc.id) baseHandleContextMenu(e) }, [documents, selectedDocuments, baseHandleContextMenu] ) - const handleEmptyContextMenu = useCallback( - (e: React.MouseEvent) => { - setContextMenuDocument(null) - baseHandleContextMenu(e) - }, - [baseHandleContextMenu] - ) + const handleEmptyContextMenu = (e: React.MouseEvent) => { + setContextMenuDocumentId(null) + baseHandleContextMenu(e) + } - const handleContextMenuClose = useCallback(() => { + const handleContextMenuClose = () => { closeContextMenu() - setContextMenuDocument(null) - }, [closeContextMenu]) + setContextMenuDocumentId(null) + } const breadcrumbs: BreadcrumbItem[] = useMemo( () => diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx index e6817dce63c..ced7d0eb350 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx @@ -1,8 +1,14 @@ -import { Button, cn, Tooltip, Trash } from '@sim/emcn' -import { Ban, Circle } from '@sim/emcn/icons' +import { Button, chipFilledFillTokens, cn, Tooltip } from '@sim/emcn' +import { Ban, Circle, Trash } from '@sim/emcn/icons' import { domAnimation, LazyMotion, m } from 'framer-motion' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +/** One source of truth for the button chrome, so the three actions read as one control strip. */ +const ACTION_BUTTON_CLASS = cn( + chipFilledFillTokens, + 'hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' +) + interface ActionBarProps { selectedCount: number onEnable?: () => void @@ -51,8 +57,10 @@ export function ActionBar({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} transition={{ duration: 0.2 }} - className={cn('-translate-x-1/2 fixed bottom-6 z-50 transform', className)} - style={{ left: '50%' }} + className={cn( + '-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)] transform', + className + )} >
@@ -63,7 +71,7 @@ export function ActionBar({ @@ -75,7 +83,7 @@ export function ActionBar({ @@ -89,9 +97,10 @@ export function ActionBar({ @@ -105,9 +114,10 @@ export function ActionBar({ @@ -121,9 +131,10 @@ export function ActionBar({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index ddeb723a94c..90862127cb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -10,9 +10,8 @@ import { ChipModalFooter, ChipModalHeader, cn, - Loader, } from '@sim/emcn' -import { RefreshCw, X } from '@sim/emcn/icons' +import { Loader, RefreshCw, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx index 8bf94aec13a..d82a9bd6937 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx @@ -13,8 +13,8 @@ import { ChipModalHeader, type ComboboxOption, handleKeyboardActivation, - Trash, } from '@sim/emcn' +import { Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import type { TagUsageData } from '@/lib/api/contracts/knowledge' import { @@ -393,7 +393,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM /> - {/* Delete Tag Confirmation Dialog */} { @@ -432,7 +431,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM )} - {/* View Documents Dialog */} >> + +function addToSet(setter: IdSetSetter, id: string) { + setter((prev) => new Set(prev).add(id)) +} + +function removeFromSet(setter: IdSetSetter, id: string) { + setter((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) +} + const STATUS_CONFIG = { active: { label: 'Active', variant: 'green' as const }, syncing: { label: 'Syncing', variant: 'amber' as const }, @@ -86,27 +103,15 @@ export function ConnectorsSection({ const [deleteTarget, setDeleteTarget] = useState(null) const [deleteDocuments, setDeleteDocuments] = useState(false) - const closeDeleteModal = useCallback(() => { + const closeDeleteModal = () => { setDeleteTarget(null) setDeleteDocuments(false) - }, []) + } const [editingConnector, setEditingConnector] = useState(null) const [error, setError] = useState(null) const [syncingIds, setSyncingIds] = useState>(() => new Set()) const [updatingIds, setUpdatingIds] = useState>(() => new Set()) - const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => { - setter((prev) => new Set(prev).add(id)) - }, []) - - const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => { - setter((prev) => { - const next = new Set(prev) - next.delete(id) - return next - }) - }, []) - const syncTriggeredAt = useRef>({}) const cooldownTimersRef = useRef> | null>(null) cooldownTimersRef.current ??= new Set() @@ -120,69 +125,61 @@ export function ConnectorsSection({ } }, []) - const isSyncOnCooldown = useCallback((connectorId: string) => { + const isSyncOnCooldown = (connectorId: string) => { const triggeredAt = syncTriggeredAt.current[connectorId] if (!triggeredAt) return false return Date.now() - triggeredAt < SYNC_COOLDOWN_MS - }, []) + } + + const handleSync = (connectorId: string, rehydrate = false) => { + if (isSyncOnCooldown(connectorId)) return - const handleSync = useCallback( - (connectorId: string, rehydrate = false) => { - if (isSyncOnCooldown(connectorId)) return - - syncTriggeredAt.current[connectorId] = Date.now() - addToSet(setSyncingIds, connectorId) - - triggerSync( - { knowledgeBaseId, connectorId, rehydrate }, - { - onSuccess: () => { - setError(null) - const timer = setTimeout(() => { - cooldownTimersRef.current?.delete(timer) - forceUpdate((n) => n + 1) - }, SYNC_COOLDOWN_MS) - cooldownTimersRef.current?.add(timer) - }, - onError: (err) => { - logger.error('Sync trigger failed', { error: err.message }) - setError(err.message) - delete syncTriggeredAt.current[connectorId] + syncTriggeredAt.current[connectorId] = Date.now() + addToSet(setSyncingIds, connectorId) + + triggerSync( + { knowledgeBaseId, connectorId, rehydrate }, + { + onSuccess: () => { + setError(null) + const timer = setTimeout(() => { + cooldownTimersRef.current?.delete(timer) forceUpdate((n) => n + 1) - }, - onSettled: () => removeFromSet(setSyncingIds, connectorId), - } - ) - }, - [knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet] - ) + }, SYNC_COOLDOWN_MS) + cooldownTimersRef.current?.add(timer) + }, + onError: (err) => { + logger.error('Sync trigger failed', { error: err.message }) + setError(err.message) + delete syncTriggeredAt.current[connectorId] + forceUpdate((n) => n + 1) + }, + onSettled: () => removeFromSet(setSyncingIds, connectorId), + } + ) + } - const handleTogglePause = useCallback( - (connector: ConnectorData) => { - addToSet(setUpdatingIds, connector.id) - updateConnector( - { - knowledgeBaseId, - connectorId: connector.id, - updates: { - status: - connector.status === 'paused' || connector.status === 'disabled' - ? 'active' - : 'paused', - }, + const handleTogglePause = (connector: ConnectorData) => { + addToSet(setUpdatingIds, connector.id) + updateConnector( + { + knowledgeBaseId, + connectorId: connector.id, + updates: { + status: + connector.status === 'paused' || connector.status === 'disabled' ? 'active' : 'paused', }, - { - onSettled: () => removeFromSet(setUpdatingIds, connector.id), - onSuccess: () => setError(null), - onError: (err) => { - logger.error('Toggle pause failed', { error: err.message }) - setError(err.message) - }, - } - ) - }, - [knowledgeBaseId, updateConnector, addToSet, removeFromSet] - ) + }, + { + onSettled: () => removeFromSet(setUpdatingIds, connector.id), + onSuccess: () => setError(null), + onError: (err) => { + logger.error('Toggle pause failed', { error: err.message }) + setError(err.message) + }, + } + ) + } const handleDeleteConnector = () => { if (!deleteTarget) return @@ -315,10 +312,10 @@ function ConnectorCard({ const serviceId = connectorDef?.auth.mode === 'oauth' ? connectorDef.auth.provider : undefined const providerId = serviceId ? getProviderIdFromServiceId(serviceId) : undefined - const requiredScopes = useMemo( - () => (connectorDef?.auth.mode === 'oauth' ? (connectorDef.auth.requiredScopes ?? []) : []), - [connectorDef] - ) + const requiredScopes = + connectorDef?.auth.mode === 'oauth' + ? (connectorDef.auth.requiredScopes ?? EMPTY_REQUIRED_SCOPES) + : EMPTY_REQUIRED_SCOPES const { data: credentials, refetch: refetchCredentials } = useOAuthCredentials(providerId, { workspaceId, @@ -573,6 +570,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } @@ -607,6 +605,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx index 5e31ae87167..640117df44b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx @@ -1,7 +1,6 @@ 'use client' -import { Plus } from '@sim/emcn' -import { Database } from '@sim/emcn/icons' +import { Database, Plus } from '@sim/emcn/icons' import { noop } from '@sim/utils/helpers' import { type BreadcrumbItem, @@ -29,7 +28,7 @@ const ACTIONS: ChromeActionSpec[] = [ const BREADCRUMBS: BreadcrumbItem[] = [ { label: KNOWLEDGE_HEADER.rootLabel, icon: Database, onClick: noop }, - { label: '…', icon: Database, terminal: true }, + { label: '…', terminal: true }, ] export default function KnowledgeBaseLoading() { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts index c7f1ae8f27e..1e3436da727 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -16,22 +16,6 @@ export const addConnectorParam = { parser: parseAsString, } as const -/** - * `page` is the 1-based document-list pagination index for this knowledge base. - * Distinct from the single-document subview's `page` (a different route). The - * default page (1) clears from the URL. - */ -export const pageParam = { - key: 'page', - parser: parseAsInteger.withDefault(1), -} as const - -/** Pagination view-state: clean URLs, no back-stack churn. */ -export const pageUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const - /** Document `enabled` filter buckets, matching the status filter dropdown. */ const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const @@ -56,12 +40,16 @@ export const kbDocumentSortParams = createSortParams(KB_SORT_COLUMNS, { }) /** - * Grouped filter/search URL state for the document list. + * Grouped filter/search/pagination URL state for the document list. * * - `q` is the document name search. The input is controlled directly by the * instant nuqs value; only its URL write is debounced via * `useDebouncedSearchSetter` — never written on every keystroke. * - `enabled` filters by processing/enabled status (`all` clears from the URL). + * - `page` is the 1-based pagination index, grouped here so a search or filter + * change resets it in the SAME write. Resetting it from a second hook escapes + * the search's debounce and writes the URL on every keystroke. Distinct from + * the single-document subview's `page`, which is a different route. * * `tagFilterEntries` is intentionally NOT represented here: it is an array of * rich filter-rule objects (slot, field type, operator, value, value-to per @@ -71,6 +59,7 @@ export const kbDocumentSortParams = createSortParams(KB_SORT_COLUMNS, { export const documentFiltersParsers = { q: parseAsString.withDefault(''), enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'), + page: parseAsInteger.withDefault(1), } as const /** Filter/search/sort view-state: clean URLs, no back-stack churn. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index d9f123c4ea7..858aeab0dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -16,15 +16,15 @@ import { ChipTextarea, type ComboboxOption, cn, - Loader, toast, } from '@sim/emcn' -import { X } from '@sim/emcn/icons' +import { Loader, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { type FieldErrors, useForm } from 'react-hook-form' import { z } from 'zod' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { StrategyOptions } from '@/lib/chunkers/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' import { @@ -57,6 +57,14 @@ const STRATEGY_OPTIONS = [ { value: 'regex', label: 'Regex (custom pattern)' }, ] as const +/** Splits the comma-separated separator field into the list the API receives. */ +function parseSeparators(value: string | undefined): string[] { + if (!value?.trim()) return [] + return value + .split(',') + .map((separator) => separator.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')) +} + const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({ label: o.label, value: o.value, @@ -124,6 +132,31 @@ const FormSchema = z path: ['regexPattern'], } ) + /** + * Gated on the strategy for the same reason the regex pattern is: the field only + * renders for `recursive` and only that strategy submits it, so an out-of-bound + * value left behind by a strategy switch must not block a submit that drops it. + */ + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).length <= MAX_CHUNKING_SEPARATORS, + { + message: `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`, + path: ['customSeparators'], + } + ) + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).every( + (separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH + ), + { + message: `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less`, + path: ['customSeparators'], + } + ) type FormInputValues = z.input type FormValues = z.output @@ -141,8 +174,8 @@ export const CreateBaseModal = memo(function CreateBaseModal({ const params = useParams() const workspaceId = params.workspaceId as string - const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId) - const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId) + const createKnowledgeBaseMutation = useCreateKnowledgeBase() + const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase() const [submitStatus, setSubmitStatus] = useState(null) const [files, setFiles] = useState([]) @@ -265,11 +298,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ ...(data.regexStrictBoundaries && { strictBoundaries: true }), } : data.strategy === 'recursive' && data.customSeparators?.trim() - ? { - separators: data.customSeparators - .split(',') - .map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')), - } + ? { separators: parseSeparators(data.customSeparators) } : undefined const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({ @@ -465,11 +494,13 @@ export const CreateBaseModal = memo(function CreateBaseModal({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx index 464a64f4f91..f52d30c48a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx @@ -137,30 +137,24 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
-

Max Size

+

Max Size

{chunkingConfig.maxSize.toLocaleString()} - - tokens - + tokens

-

Min Size

+

Min Size

{chunkingConfig.minSize.toLocaleString()} - - chars - + chars

-

Overlap

+

Overlap

{chunkingConfig.overlap.toLocaleString()} - - tokens - + tokens

diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx index c14cc716e7c..3fc840e5322 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx @@ -85,4 +85,30 @@ describe('useKnowledgeUpload admission', () => { unmount() }) + + /** + * A partial batch failure still created every document that DID upload, so the caches have + * to reconcile on the throwing path too — otherwise the list renders without rows the + * server already has. + */ + it('reconciles the caches when part of a batch fails', async () => { + const onError = vi.fn() + const { result, unmount } = renderKnowledgeUploadHook(onError) + mockUploadKnowledgeDocumentSession + .mockResolvedValueOnce({ id: 'doc-1', filename: 'ok.bin' }) + .mockRejectedValueOnce(new Error('network died')) + + await act(async () => { + await expect( + result().uploadFiles([sizedFile('ok.bin', 10), sizedFile('bad.bin', 10)], 'kb-1') + ).rejects.toMatchObject({ code: 'PARTIAL_UPLOAD_FAILURE' }) + }) + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['knowledge', 'detail', 'kb-1'], + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['knowledge', 'list'] }) + + unmount() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 5923e9e03f6..90ae6736e20 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -119,6 +119,14 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { }) } + /** Reconciles both caches an upload moves: the base's documents and the list's `docCount`. */ + const invalidateKnowledgeCaches = async (knowledgeBaseId: string) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }), + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }), + ]) + } + const uploadFilesInBatches = async ( files: File[], knowledgeBaseId: string, @@ -209,16 +217,21 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { setUploadProgress((prev) => ({ ...prev, stage: 'processing' })) logger.info(`Successfully started processing ${uploadedDocuments.length} documents`) - await Promise.all([ - queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }), - /** The knowledge-base list rows carry `docCount`, so an upload changes them too. */ - queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }), - ]) + await invalidateKnowledgeCaches(knowledgeBaseId) return uploadedDocuments } catch (err) { logger.error('Error uploading documents:', err) + /** + * A partial batch failure still created every document that did upload, so the caches + * must reconcile on this path too — otherwise the list is missing rows that exist until + * its staleTime expires. Admission failures create nothing and need no refetch. + */ + if (err instanceof KnowledgeUploadError && err.code === 'PARTIAL_UPLOAD_FAILURE') { + void invalidateKnowledgeCaches(knowledgeBaseId) + } + const error: UploadError = err instanceof KnowledgeUploadError ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index e01bee4bd83..207f8b9599e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' -import { Button, ChipConfirmModal, ChipDropdown, Plus, Tooltip, toast } from '@sim/emcn' -import { Database, FolderPlus, Pencil, Trash } from '@sim/emcn/icons' +import { Button, ChipConfirmModal, ChipDropdown, Tooltip, toast } from '@sim/emcn' +import { Database, FolderPlus, Pencil, Plus, Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { @@ -22,9 +23,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +39,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +49,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -65,7 +74,12 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' -import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { + useBulkDeleteKnowledgeBases, + useBulkMoveKnowledgeBases, + useDeleteKnowledgeBase, + useUpdateKnowledgeBase, +} from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -110,7 +124,9 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'empty', label: 'Empty' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' +/** This list's private drag MIME, so a drag started on another list is never mistaken for one + * of these rows. */ +const KNOWLEDGE_ROW_DRAG_MIME = 'application/x-sim-workspace-knowledge-rows' const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel @@ -200,9 +216,14 @@ export function Knowledge() { }, [error]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true + const canEditRef = useRef(canEdit) + canEditRef.current = canEdit - const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) - const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase() + const deleteKnowledgeBase = useDeleteKnowledgeBase() + const bulkMoveKnowledgeBases = useBulkMoveKnowledgeBases(workspaceId) + const bulkDeleteKnowledgeBases = useBulkDeleteKnowledgeBases(workspaceId) const { currentFolderId, @@ -268,8 +289,8 @@ export function Knowledge() { ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false) const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [activeFolder, setActiveFolder] = useState(null) const [folderPendingDelete, setFolderPendingDelete] = useState(null) @@ -310,6 +331,22 @@ export function Knowledge() { const activeFolderRef = useRef(activeFolder) activeFolderRef.current = activeFolder + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const knowledgeBaseById = useMemo(() => { + const byId = new Map() + for (const base of knowledgeBases) byId.set(base.id, base as KnowledgeBaseWithDocCount) + return byId + }, [knowledgeBases]) + const knowledgeBaseByIdRef = useRef(knowledgeBaseById) + knowledgeBaseByIdRef.current = knowledgeBaseById + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById + const foldersRef = useRef(folders) foldersRef.current = folders @@ -400,10 +437,11 @@ export function Knowledge() { const handleDeleteKnowledgeBase = useCallback( async (id: string) => { - await deleteKnowledgeBaseMutation({ knowledgeBaseId: id }) + await deleteKnowledgeBase.mutateAsync({ knowledgeBaseId: id }) logger.info(`Knowledge base deleted: ${id}`) }, - [deleteKnowledgeBaseMutation] + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [] ) /** @@ -613,6 +651,56 @@ export function Knowledge() { listRename.cancelRename, ]) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isCreateModalOpen || + isEditModalOpen || + isDeleteModalOpen || + isBulkDeleteModalOpen || + isTagsModalOpen || + folderPendingDelete !== null + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => + !canEdit || listRenameRef.current.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * the menu handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const { folderIds: selectedFolderIds, resourceIds: selectedKnowledgeBaseIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteCount = selectedKnowledgeBaseIds.length + selectedFolderIds.length + const bulkDeleteFirstName = + selectedKnowledgeBaseIds.length > 0 + ? knowledgeBases.find((kb) => kb.id === selectedKnowledgeBaseIds[0])?.name + : folders.find((folder) => folder.id === selectedFolderIds[0])?.name + const bulkDeleteLabel = selectionLabel(bulkDeleteCount, bulkDeleteFirstName) + const handleRowClick = useCallback( (rowId: string) => { if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return @@ -634,6 +722,13 @@ export function Knowledge() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEditRef.current && !selectedRowIdsRef.current.has(rowId)) replaceSelection([rowId]) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { const folder = foldersRef.current.find((item) => item.id === parsed.id) @@ -655,14 +750,9 @@ export function Knowledge() { const handleConfirmDelete = useCallback(async () => { const kb = activeKnowledgeBaseRef.current if (!kb) return - setIsDeleting(true) - try { - await handleDeleteKnowledgeBase(kb.id) - setIsDeleteModalOpen(false) - setActiveKnowledgeBase(null) - } finally { - setIsDeleting(false) - } + await handleDeleteKnowledgeBase(kb.id) + setIsDeleteModalOpen(false) + setActiveKnowledgeBase(null) }, [handleDeleteKnowledgeBase]) const handleCloseDeleteModal = useCallback(() => { @@ -696,8 +786,6 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true - const handleCreateFolder = useCallback(async () => { if (!workspaceId) return const parentId = currentFolderIdRef.current @@ -800,16 +888,18 @@ export function Knowledge() { }, [workspaceId, pinnedFolderIds, closeFolderContextMenu]) /** Move targets for the folder under the cursor: itself and its subtree are unreachable. */ - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ - folders, - rootLabel: ROOT_BREADCRUMB_LABEL, - excludedFolderIds: excluded, - }) - }, [folders, activeFolder, descendantsByFolderId]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId, + }) + : [], + [folders, activeFolder, descendantsByFolderId] + ) /** Move targets for a knowledge base: every folder, since a base has no subtree. */ const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo( @@ -855,8 +945,7 @@ export function Knowledge() { if (!folder) return const parentId = parseMoveOptionValue(optionValue) // Live placement, not the snapshot taken when the menu opened — a refetch or concurrent - // move in between would otherwise skip the write the user just chose. Matches the - // knowledge-base move below and both Tables handlers. + // move in between would otherwise skip the write the user just chose. const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) closeFolderContextMenu() @@ -869,8 +958,7 @@ export function Knowledge() { const kb = activeKnowledgeBaseRef.current if (!kb) return const folderId = parseMoveOptionValue(optionValue) - // Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when - // the menu opened, and a refetch since then would make the no-op check wrong. + // Same reasoning as `handleMoveFolder`: compare against the live row, not the snapshot. const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) closeRowContextMenu() @@ -878,22 +966,138 @@ export function Knowledge() { [moveKnowledgeBaseTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of knowledge bases and + * folders commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { knowledgeBaseIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.knowledgeBaseIds.length === 0 && rows.folderIds.length === 0) return + if (rows.knowledgeBaseIds.length + rows.folderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveKnowledgeBases.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { knowledgeBaseIds: selectedKnowledgeBaseIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedKnowledgeBaseIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = + selectedKnowledgeBaseIds.length + selectedFolderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteModalOpen(true) + }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteKnowledgeBases.mutateAsync({ + knowledgeBaseIds: selectedKnowledgeBaseIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteModalOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (deleteError) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items', deleteError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection]) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId, + }), + [selectedFolderIds, folders, descendantsByFolderId] + ) + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : knowledgeBaseMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleDelete() + }, [handleBulkDelete, handleDelete]) + + const handleFolderDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleRequestFolderDelete() + }, [handleBulkDelete, handleRequestFolderDelete]) + + const handleMoveKnowledgeBaseFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveKnowledgeBase(optionValue) + }, + [handleBulkMove, handleMoveKnowledgeBase] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: KNOWLEDGE_ROW_DRAG_MIME, canEdit, editingRowId: listRename.editingId, descendantsByFolderId, - getFolderParentId: (folderId) => foldersRef.current.find((f) => f.id === folderId)?.parentId, + getFolderParentId: (folderId) => folderByIdRef.current.get(folderId)?.parentId ?? null, getResourceFolderId: (knowledgeBaseId) => - knowledgeBasesRef.current.find((kb) => kb.id === knowledgeBaseId)?.folderId ?? null, + knowledgeBaseByIdRef.current.get(knowledgeBaseId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' - ? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder') - : (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base') + ? (folderByIdRef.current.get(parsed.id)?.name ?? 'Folder') + : (knowledgeBaseByIdRef.current.get(parsed.id)?.name ?? 'Knowledge base') }, - onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId), - onMoveResource: (knowledgeBaseId, targetFolderId) => - void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, knowledgeBaseIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -996,18 +1200,7 @@ export function Knowledge() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -1089,6 +1282,36 @@ export function Knowledge() { [connectorFilter, contentFilter, ownerFilter, memberOptions] ) + /** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */ + const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) + + /** + * Memoized element, not inline JSX: `Resource.Table` is `memo`'d, and a fresh overlay element + * every render would fail its shallow compare and re-render the whole list on any parent + * render — during an upload or a drag, that is every frame. + */ + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveKnowledgeBases.isPending, + bulkDeleteKnowledgeBases.isPending, + ] + ) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (connectorFilter.length > 0) { @@ -1123,19 +1346,22 @@ export function Knowledge() { title={ROOT_BREADCRUMB_LABEL} breadcrumbs={listBreadcrumbs} actions={headerActions} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1160,9 +1386,9 @@ export function Knowledge() { onTogglePin={handleToggleBasePin} pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} - onDelete={handleDelete} - onMove={handleMoveKnowledgeBase} - moveOptions={knowledgeBaseMoveOptions} + onDelete={handleDeleteFromMenu} + onMove={handleMoveKnowledgeBaseFromMenu} + moveOptions={activeMoveOptions} showOpenInNewTab showViewTags showEdit @@ -1179,12 +1405,12 @@ export function Knowledge() { onClose={closeFolderContextMenu} onOpen={handleOpenFolder} onRename={handleRenameFolder} - onDelete={handleRequestFolderDelete} + onDelete={handleFolderDeleteFromMenu} onCopyId={handleCopyFolderId} onTogglePin={handleToggleFolderPin} pinned={pinnedFolderIds.has(activeFolder.id)} - onMove={handleMoveFolder} - moveOptions={folderMoveOptions} + onMove={handleMoveFolderFromMenu} + moveOptions={activeFolderMoveOptions} canEdit={canEdit} /> )} @@ -1194,8 +1420,8 @@ export function Knowledge() { onOpenChange={(open) => { if (!open) setFolderPendingDelete(null) }} - srTitle='Delete folder' - title='Delete folder' + srTitle='Delete Folder' + title='Delete Folder' text={[ 'Are you sure you want to delete ', { text: folderPendingDelete?.name ?? 'this folder', bold: true }, @@ -1209,6 +1435,26 @@ export function Knowledge() { }} /> + 0 + ? '? This also deletes the knowledge bases and folders inside the selected folders. You can restore them from Recently Deleted in Settings.' + : '? You can restore them from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteKnowledgeBases.isPending, + pendingLabel: 'Deleting...', + }} + /> + {activeKnowledgeBase && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx index c2de47d0f0b..66921bb0f43 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx @@ -1,7 +1,6 @@ 'use client' -import { Plus } from '@sim/emcn' -import { Database, FolderPlus } from '@sim/emcn/icons' +import { Database, FolderPlus, Plus } from '@sim/emcn/icons' import { type ChromeActionSpec, ResourceChromeFallback, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index ef9f1a9e5c5..9cdae3dcb08 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -69,7 +69,6 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', - 'credential-groups', 'mcp', 'custom-tools', 'byok', @@ -77,6 +76,7 @@ describe('unified settings navigation', () => { 'workflow-mcp-servers', 'apikeys', 'sandboxes', + 'credential-groups', 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index f369e93d363..af00af4b441 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -1,6 +1,15 @@ /** Tailwind class applied to selected rows / columns / cells. */ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' +/** + * Fill marking every cell matching the active find query. Reuses the app's + * search-highlight token (the knowledge-base search highlight paints with the + * same one) rather than inventing a third match colour, so the two stay + * theme-tuned together. The ACTIVE match is told apart by the selection + * outline drawn over it, not by a different fill. + */ +export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]' + /** Default column width in pixels. Used as a fallback when a column hasn't * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 @@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX = /** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */ export const CELL_CONTENT = 'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small' -export const SELECTION_OVERLAY = - 'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]' +/** Inset shared by every full-cell overlay, so the tints and the selection + * outline can't drift apart on a border-geometry change. */ +export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px' +export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]` diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index acf192e3002..077f73fb2e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -12,6 +12,8 @@ import { CELL, CELL_CHECKBOX, CELL_CONTENT, + CELL_OVERLAY_INSET, + FIND_MATCH_TINT_BG, SELECTION_OVERLAY, SELECTION_TINT_BG, } from './constants' @@ -67,6 +69,13 @@ export interface DataRowProps { pinnedOffsets?: Map /** Key of the rightmost pinned column, used to render a separator shadow. */ lastPinnedColKey?: string | null + /** + * Column keys in this row matching the active find query, tinted so every hit + * is visible at once rather than only the one being navigated to. Absent when + * the row has no match, which is the common case and keeps this row's memo + * from re-running for a search elsewhere in the table. + */ + findMatchColumns?: ReadonlySet } function cellRangeRowChanged( @@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.workflowGroups !== next.workflowGroups || prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || - prev.lastPinnedColKey !== next.lastPinnedColKey + prev.lastPinnedColKey !== next.lastPinnedColKey || + prev.findMatchColumns !== next.findMatchColumns ) { return false } @@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({ activeDispatches, pinnedOffsets, lastPinnedColKey, + findMatchColumns, }: DataRowProps) { const sel = normalizedSelection /** @@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({ const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol const isEditing = editingColumnName === column.key const isHighlighted = inRange || isRowChecked + const isFindMatch = findMatchColumns?.has(column.key) const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked @@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({ data-pinned={isPinnedCell ? '' : undefined} className={cn( CELL, - (isHighlighted || isAnchor || isEditing) && 'relative', + (isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative', isPinnedCell && 'z-[6] bg-[var(--bg)]', isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]' )} @@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({ } onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)} > + {/* No z-index on purpose: with `auto` it paints in DOM order, so it + sits above the cell background but BELOW the cell text, the + selection tint (z-4) and the anchor outline (z-5). The active + match therefore still reads as the selected cell, and the wash + never dims the value it is pointing at. */} + {isFindMatch && ( +
+ )} {isHighlighted && (isMultiCell || isRowChecked) && (
ghost.parentNode?.removeChild(ghost)) - onDragStart(columnName) + onDragStart(columnKey) } function handleDragOver(e: React.DragEvent) { - if (!onDragOver || !columnName) return + if (!onDragOver) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const side = e.clientX < midX ? 'left' : 'right' - onDragOver(columnName, side) + onDragOver(columnKey, side) } function handleDragEnd() { @@ -457,6 +459,8 @@ export function WorkflowGroupMetaCell({ return ( ({ + Button: ({ children, ...props }: { children: ReactNode } & Record) => ( + + ), + ChipInput: ({ + endAdornment, + icon: _icon, + ...props + }: { endAdornment?: ReactNode } & Record) => ( + <> + + {endAdornment} + + ), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => , + ChevronUp: () => , + Loader: () => , + Search: () => , + X: () => , +})) + +import { TableFind, type TableFindProps } from './table-find' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(overrides: Partial = {}) { + const props: TableFindProps = { + query: '', + onQueryChange: vi.fn(), + onNext: vi.fn(), + onPrev: vi.fn(), + onSubmit: vi.fn(), + onClose: vi.fn(), + isStale: false, + canNavigate: true, + count: 0, + currentIndex: 0, + truncated: false, + isLoading: false, + inputRef: createRef(), + ...overrides, + } + act(() => root.render()) + return props +} + +function input(): HTMLInputElement { + const el = container.querySelector('input') + if (!el) throw new Error('find input not rendered') + return el +} + +function counterText(): string | null { + return container.querySelector('[aria-live="polite"]')?.textContent ?? null +} + +function buttonByLabel(label: string): HTMLButtonElement { + const el = container.querySelector(`button[aria-label="${label}"]`) + if (!el) throw new Error(`no button labelled ${label}`) + return el as HTMLButtonElement +} + +function press(key: string, init: KeyboardEventInit = {}) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +describe('TableFind counter', () => { + it('shows nothing before the user has typed', () => { + render({ query: '' }) + expect(counterText()).toBe('') + }) + + it('counts matches as 1-based', () => { + render({ query: 'a', count: 12, currentIndex: 0 }) + expect(counterText()).toBe('1 of 12') + render({ query: 'a', count: 12, currentIndex: 11 }) + expect(counterText()).toBe('12 of 12') + }) + + it('marks a server-capped result set', () => { + render({ query: 'a', count: 1000, currentIndex: 0, truncated: true }) + expect(counterText()).toBe('1 of 1000+') + }) + + it('says No results only once the search has settled', () => { + render({ query: 'zzz', count: 0, isLoading: true }) + expect(counterText()).toBe('') + expect(container.querySelector('[data-icon="loader"]')).not.toBeNull() + + render({ query: 'zzz', count: 0, isLoading: false }) + expect(counterText()).toBe('No results') + }) + + // Blanking the tally on each keystroke reads as the search breaking; the + // previous term's count holds until the new one lands. + it('keeps the previous count visible while the next result set loads', () => { + render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true }) + expect(counterText()).toBe('2 of 3') + }) + + it('keeps the counter mounted and width-reserved before the user types', () => { + render({ query: '' }) + const region = container.querySelector('[aria-live="polite"]') + expect(region).not.toBeNull() + expect(region?.className).toContain('min-w-[64px]') + }) +}) + +describe('TableFind keyboard', () => { + it('navigates on Enter rather than submitting a search', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter') + expect(props.onNext).toHaveBeenCalledTimes(1) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('steps backwards on Shift+Enter', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter', { shiftKey: true }) + expect(props.onPrev).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + }) + + // Committing makes the typed and submitted terms agree instantly, but the + // matches on screen still belong to the previous term until the request + // lands — stepping there would select a cell the box no longer names. + it('does not step while the committed term is still loading', () => { + const props = render({ query: 'abcd', count: 3, isStale: false, canNavigate: false }) + press('Enter') + expect(props.onNext).not.toHaveBeenCalled() + expect(props.onSubmit).not.toHaveBeenCalled() + + press('Enter', { shiftKey: true }) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('disables the arrows until the results describe the term', () => { + render({ query: 'abcd', count: 3, canNavigate: false }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + }) + + it('closes on Escape', () => { + const props = render({ query: 'a', count: 3 }) + press('Escape') + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + // Mid-debounce the visible matches still belong to the previous term, so + // stepping through them would land on a cell the box no longer describes. + it('commits instead of stepping while the results are stale', () => { + const props = render({ query: 'abcd', count: 3, isStale: true }) + press('Enter') + expect(props.onSubmit).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + + press('Enter', { shiftKey: true }) + expect(props.onSubmit).toHaveBeenCalledTimes(2) + expect(props.onPrev).not.toHaveBeenCalled() + }) +}) + +describe('TableFind controls', () => { + it('offers a clear button only once there is text', () => { + render({ query: '' }) + expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull() + + const props = render({ query: 'abc' }) + act(() => buttonByLabel('Clear search').click()) + expect(props.onQueryChange).toHaveBeenCalledWith('') + }) + + it('disables navigation while there is nothing to navigate', () => { + render({ query: 'zzz', count: 0 }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + + render({ query: 'a', count: 2 }) + expect(buttonByLabel('Next match').disabled).toBe(false) + expect(buttonByLabel('Previous match').disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx index 58b9220bdcb..a48a7e0122e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx @@ -1,53 +1,68 @@ 'use client' import type React from 'react' +import { memo } from 'react' import { Button, ChipInput } from '@sim/emcn' -import { ChevronDown, ChevronUp, Loader, X } from '@sim/emcn/icons' +import { ChevronDown, ChevronUp, Loader, Search, X } from '@sim/emcn/icons' export interface TableFindProps { query: string onQueryChange: (query: string) => void - /** Run the search (dirty Enter / search button). */ - onSubmit: () => void onNext: () => void onPrev: () => void + /** Adopts the typed term immediately, skipping the debounce. */ + onSubmit: () => void onClose: () => void + /** Whether the typed term has yet to be searched, so Enter should commit it. */ + isStale: boolean + /** + * Whether the matches on screen belong to the term that was searched. False + * while a term's own results are in flight, when the count still describes + * the previous term and stepping through it would land on a cell the box no + * longer names. + */ + canNavigate: boolean /** Number of matches after dropping columns not in the current view. */ count: number - /** 0-based index of the active match, or -1 when there are none. */ + /** 0-based index of the active match. Ignored when `count` is 0. */ currentIndex: number /** Whether the server capped the match set. */ truncated: boolean isLoading: boolean - /** Whether the input differs from the last submitted term. */ - isDirty: boolean inputRef: React.RefObject } -export function TableFind({ +/** + * Memoized: while the bar is open it is a child of the grid, which re-renders + * on scroll, hover and selection. Every prop is a primitive or a stable + * identity, so this collapses to renders where a find value actually changed. + */ +export const TableFind = memo(function TableFind({ query, onQueryChange, - onSubmit, onNext, onPrev, + onSubmit, onClose, + isStale, + canNavigate, count, currentIndex, truncated, isLoading, - isDirty, inputRef, }: TableFindProps) { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() - if (e.shiftKey) { - onPrev() - } else if (isDirty) { - onSubmit() - } else { - onNext() - } + // Commit an unsearched term; otherwise step — but only once the results + // describe it. In between (committed, still loading) Enter does nothing + // rather than walk the previous term's matches; the auto-reveal lands on + // the first hit as soon as they arrive. + if (isStale) onSubmit() + else if (!canNavigate) return + else if (e.shiftKey) onPrev() + else onNext() return } if (e.key === 'Escape') { @@ -56,9 +71,17 @@ export function TableFind({ } } + const hasQuery = query.trim().length > 0 const hasMatches = count > 0 - const label = - count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + const navEnabled = hasMatches && canNavigate + + /** The tally holds its last value while the next result set loads — blanking + * it on every keystroke reads as the feature breaking rather than working. */ + function counterContent() { + if (!hasQuery) return null + if (hasMatches) return `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + return isLoading ? : 'No results' + } return (
@@ -66,42 +89,77 @@ export function TableFind({ ref={inputRef} value={query} placeholder='Search' + aria-label='Find in table' + spellCheck={false} + autoComplete='off' + icon={Search} className='w-[200px]' onChange={(e) => onQueryChange(e.target.value)} onKeyDown={handleKeyDown} + // Untrimmed on purpose: whitespace searches nothing, but it is still + // text the user may want cleared. + endAdornment={ + query.length > 0 ? ( + + ) : undefined + } /> - - {isLoading ? : label} + {/* Always mounted, reserving its width: rendering it only once there is a + query would resize the bar on the first keystroke, and a live region + inserted together with its text is announced unreliably. */} + + {counterContent()}
) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 59222045098..f69e0527069 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -27,6 +27,7 @@ import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -78,6 +79,7 @@ import { drainTargetForChip, type ExecStatusMix, expandToDisplayColumns, + horizontalEdgeScrollVelocity, isCellInSelection, moveCell, ROW_SELECTION_ALL, @@ -94,11 +96,14 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FIND_MATCH_COLUMNS: ReadonlyMap> = Object.freeze(new Map()) const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 const ROW_HEIGHT_ESTIMATE = 35 +const COLUMN_DRAG_SCROLL_HOT_ZONE_PX = 48 +const COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX = 14 /** * Snapshot of grid selection state the wrapper needs to render ``. @@ -481,11 +486,9 @@ export function TableGrid({ const [selectionFocus, setSelectionFocus] = useState(null) const [rowSelection, setRowSelection] = useState(ROW_SELECTION_NONE) const [isColumnSelection, setIsColumnSelection] = useState(false) - // Find (Cmd/Ctrl+F): `findQuery` is the live input, `submittedQuery` is the - // last Enter/search-triggered term the query hook runs on. + // Find (Cmd/Ctrl+F): `findQuery` is the live input. const [findOpen, setFindOpen] = useState(false) const [findQuery, setFindQuery] = useState('') - const [submittedQuery, setSubmittedQuery] = useState('') const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const [isJumping, setIsJumping] = useState(false) // Bumped on every navigation so the reveal effect re-runs even when the target @@ -493,6 +496,18 @@ export function TableGrid({ const [pendingMatchTick, setPendingMatchTick] = useState(0) const findInputRef = useRef(null) const pendingMatchRef = useRef(null) + /** Monotonic id for the in-flight match jump; see `goToMatch`. */ + const goToMatchSeqRef = useRef(0) + /** The match the cursor is on, by identity rather than position, so a + * reordered result set can re-point at the same cell. */ + const activeMatchRef = useRef(null) + /** Term the auto-reveal has already run for, so a background refetch of the + * same term doesn't re-jump the viewport. */ + const autoRevealedTermRef = useRef('') + /** Whether the selection currently sits on the match at `currentMatchIndex`. + * False when the auto-reveal was skipped, so next/prev knows to land on that + * index rather than step past it. */ + const cursorIsOnMatchRef = useRef(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -532,6 +547,8 @@ export function TableGrid({ const seededLayoutKeyRef = useRef(null) const containerRef = useRef(null) const scrollRef = useRef(null) + const columnDragPointerXRef = useRef(null) + const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) const isDraggingRef = useRef(false) @@ -1093,7 +1110,51 @@ export function TableGrid({ emitCellSelection({ anchor, focus, editing: editingCell !== null }) }, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection]) - const { data: findData, isFetching: isFindFetching } = useFindTableRows({ + /** + * The term the search actually runs on: the live input, debounced so results + * follow typing without a request per keystroke. + * + * Owned here rather than via `useDebounce` because closing or clearing has to + * take effect IMMEDIATELY and cancel anything pending. `useDebounce` is + * trailing-edge and keeps serving its last value until the next timer fires, + * so after Esc it still holds the old term — and a guard on the *input* can't + * mask that, because the first keystroke of the next search makes the input + * non-empty again while the debounce is still holding the previous term. The + * result would be the old search replayed from cache (highlights, count and a + * viewport jump) under a box showing one fresh character. Cmd+F, Esc, Cmd+F + * is an ordinary correction, so that window gets hit. + */ + const trimmedFindQuery = findQuery.trim() + const [submittedQuery, setSubmittedQuery] = useState('') + useEffect(() => { + if (!findOpen || trimmedFindQuery.length === 0) { + setSubmittedQuery('') + return + } + const timer = setTimeout(() => setSubmittedQuery(trimmedFindQuery), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [findOpen, trimmedFindQuery]) + + const trimmedFindQueryRef = useRef(trimmedFindQuery) + trimmedFindQueryRef.current = trimmedFindQuery + + /** + * Adopt the typed term now instead of waiting out the debounce. Enter uses + * this while the two disagree: navigating there would step through the + * PREVIOUS term's matches — `keepPreviousData` still holds them — and land on + * a cell that doesn't match the box. Pressing Enter means "search this now", + * so it commits rather than navigates, and the auto-reveal takes it from + * there. The pending timer is harmless: it later sets the same string. + */ + const handleFindSubmit = useCallback(() => { + setSubmittedQuery(trimmedFindQueryRef.current) + }, []) + + const { + data: findData, + isFetching: isFindFetching, + isPlaceholderData: isFindPlaceholder, + } = useFindTableRows({ workspaceId, tableId, q: submittedQuery, @@ -1108,6 +1169,11 @@ export function TableGrid({ * to a cell that isn't rendered. */ const findMatches = useMemo(() => { + // `keepPreviousData` serves the previous term's matches while a new term + // loads, which is what keeps the counter steady mid-typing — but with an + // empty term the query is disabled, so that placeholder would otherwise + // linger as highlights over a cleared search box. + if (submittedQuery.length === 0) return EMPTY_FIND_MATCHES const raw = findData?.matches if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES // `m.column` is the stable column id (the JSONB storage key); index display @@ -1120,15 +1186,58 @@ export function TableGrid({ a.ordinal - b.ordinal || (colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0) ) - }, [findData, displayColumns]) + }, [findData, displayColumns, submittedQuery]) + + /** + * Match column ids grouped by row id, so a row can mark its matching cells in + * O(1) without scanning the whole match list. Rebuilt only when the match set + * changes; `DataRow` is memoized on the per-row `Set`, so rows without a match + * keep the same `undefined` and never re-render for a search. + */ + const findMatchColumnsByRowId = useMemo>>(() => { + if (findMatches.length === 0) return EMPTY_FIND_MATCH_COLUMNS + const byRow = new Map>() + for (const match of findMatches) { + const existing = byRow.get(match.rowId) + if (existing) existing.add(match.column) + else byRow.set(match.rowId, new Set([match.column])) + } + return byRow + }, [findMatches]) + + /** + * Whether the matches on screen actually belong to the submitted term. + * + * False while a term's own results are still in flight — `keepPreviousData` + * keeps serving the PREVIOUS term's matches until they land, and the first + * search of a session has no data at all. Navigation is gated on this: + * committing with Enter makes the typed and submitted terms agree instantly, + * so without it a second Enter would step through the old term's matches. + * + * A background refetch of the SAME term keeps this true — its data is still + * for this key — so an SSE row update doesn't disable the arrows mid-search. + */ + const findResultsAreCurrent = + submittedQuery.length > 0 && findData !== undefined && !isFindPlaceholder const findMatchesRef = useRef(findMatches) findMatchesRef.current = findMatches + const findResultsAreCurrentRef = useRef(findResultsAreCurrent) + findResultsAreCurrentRef.current = findResultsAreCurrent const currentMatchIndexRef = useRef(currentMatchIndex) currentMatchIndexRef.current = currentMatchIndex const findOpenRef = useRef(findOpen) findOpenRef.current = findOpen + /** + * Whether `match` is still in the live result set. Both the paging await and + * the deferred reveal can outlast a refetch that removed it, and revealing a + * cell that no longer matches would select a non-hit and mark the cursor as + * sitting on a result. + */ + const isStillAMatch = (match: TableFindMatch) => + findMatchesRef.current.some((m) => m.rowId === match.rowId && m.column === match.column) + /** Loads the row containing match `index` (wrapping), then queues the cell reveal. */ const goToMatch = useCallback(async (index: number) => { const matches = findMatchesRef.current @@ -1136,11 +1245,30 @@ export function TableGrid({ const wrapped = ((index % matches.length) + matches.length) % matches.length const match = matches[wrapped] setCurrentMatchIndex(wrapped) + // Claim the target NOW, not when the reveal lands. Paging is awaited below, + // and a same-term refetch during that window would otherwise re-point the + // cursor at the cell we are navigating AWAY from. + activeMatchRef.current = match setIsJumping(true) + // Paging to a distant match can outlast the next keystroke now that the + // search runs as the user types. Stamp this jump and drop it on return if a + // newer one started, or the grid would land on a superseded term's match. + const seq = ++goToMatchSeqRef.current try { await ensureRowsLoadedUpToRef.current(match.ordinal + 1) } finally { - setIsJumping(false) + if (seq === goToMatchSeqRef.current) setIsJumping(false) + } + if (seq !== goToMatchSeqRef.current) return + // The match set can change while we page — find hangs off the rows cache, + // so any row write or SSE update refetches it. If the target is gone, + // revealing it would select a cell that no longer matches and mark the + // cursor as sitting on a result, which then makes the next step skip the + // match that replaced it. + if (!isStillAMatch(match)) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + return } // Defer the anchor set to the reveal effect: it must run after the freshly // loaded rows have committed, else scrollToIndex clamps to the stale count. @@ -1148,6 +1276,31 @@ export function TableGrid({ setPendingMatchTick((t) => t + 1) }, []) + /** + * Editing the query strands a jump still paging toward the previous term's + * match: without this, that jump can finish, pass its own sequence check, and + * reveal a cell that no longer matches — most visibly when the new term's + * first hit isn't loaded, so nothing else moves the selection afterwards. + * + * Declared above BOTH the reveal and the auto-reveal effects so it runs + * first. Effects fire in declaration order, so if a queued reveal and a + * keystroke land in the same commit, a cancel declared later would clear + * `pendingMatchRef` only after the reveal had already applied the stale match. + * + * Keyed on the LIVE input, not the debounced term: during the debounce window + * the submitted term still names the old search, so keying on it would leave + * that jump valid for another `SEARCH_DEBOUNCE_MS` after the box already + * shows something else. Clearing and closing land here too — both blank the + * input. + */ + useEffect(() => { + goToMatchSeqRef.current++ + pendingMatchRef.current = null + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + setIsJumping(false) + }, [trimmedFindQuery, findOpen]) + /** * Reveal the pending match's cell once its row is in the loaded window. Keyed * on `rows` (new pages) and `pendingMatchTick` (so it fires even when the row @@ -1157,6 +1310,23 @@ export function TableGrid({ useEffect(() => { const match = pendingMatchRef.current if (!match) return + // Last gate before the selection moves: the queue-to-reveal hop is another + // commit the result set can change under, so re-check here too rather than + // trusting the check `goToMatch` made before its await. + if (!isStillAMatch(match)) { + pendingMatchRef.current = null + // Release the cursor only if this reveal still owns it. A pending reveal + // waits here for its row to load, and the user can start a newer jump in + // that window — which has already claimed the ref. Clearing it blindly + // would strand that newer jump with no identity to re-point from, which + // is the skip-on-next failure this guard exists to prevent. + const active = activeMatchRef.current + if (active && active.rowId === match.rowId && active.column === match.column) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + } + return + } const rowIndex = rows.findIndex((r) => r.id === match.rowId) if (rowIndex === -1) return const colIndex = displayColumns.findIndex((c) => c.key === match.column) @@ -1166,35 +1336,150 @@ export function TableGrid({ setIsColumnSelection(false) setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setSelectionFocus(null) + cursorIsOnMatchRef.current = true setSelectionAnchor({ rowIndex, colIndex }) }, [rows, displayColumns, pendingMatchTick]) - /** New result set (new submitted term) → reset to and reveal the first match. */ + /** + * Re-point the cursor at the match it is actually on after the set changes. + * + * The cursor is stored as an index, but the list underneath it is mutable: a + * row insert or delete elsewhere in the table reorders matches for the SAME + * term, and index 1 can silently become a different cell. Stepping from it + * would then revisit the cell the user is on, or skip its neighbour. Matching + * on (rowId, column) — the match's identity — keeps the cursor attached to the + * cell rather than the position. + * + * When the active match is gone from the set — its row deleted, its cell + * edited so it no longer matches — the cursor is released instead: it is no + * longer sitting on a hit, so the next step must LAND on the clamped index + * rather than move past it. Without that, deleting the match under the cursor + * makes Next skip the one that took its place. + */ + useEffect(() => { + const active = activeMatchRef.current + if (!active || findMatches.length === 0) return + const index = findMatches.findIndex( + (m) => m.rowId === active.rowId && m.column === active.column + ) + if (index === -1) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + setCurrentMatchIndex((i) => Math.min(i, findMatches.length - 1)) + return + } + if (index !== currentMatchIndexRef.current) setCurrentMatchIndex(index) + }, [findMatches]) + + /** + * A new TERM resets to its first match and reveals it. + * + * Keyed on the term, not on `findMatches` identity: the find query hangs off + * the rows cache, so any row write or SSE update refetches it, and keying on + * the result set would yank a user reading match 7 back to match 1 whenever + * a workflow cell landed. + * + * The reveal is skipped when the match is outside the loaded window. + * `ensureRowsLoadedUpTo` pages sequentially, so a selective term whose first + * hit is 50k rows down would fire ~50 serial round trips — per typing pause, + * now that the search is live. Highlights and the count still cover the whole + * table; only the viewport jump waits for a deliberate Enter or next-click. + * + * That deliberate path still runs the same unbounded, uncancellable paging it + * always has; this only stops typing from triggering it. Bounding it properly + * wants a fetch-at-offset on the rows endpoint, which is a server change. + */ useEffect(() => { + if (submittedQuery.length === 0) { + // Clearing the box has to un-latch, or retyping the same term — the + // ordinary "did I typo that?" correction — would match the stale latch + // and neither reset the cursor nor reveal anything. + autoRevealedTermRef.current = '' + return + } + // Wait for THIS term's own result set. `keepPreviousData` leaves + // `findMatches` describing the previous term while the new one loads, and + // on the session's first search there is no previous data at all — so + // `isPlaceholderData` is false while the query is still pending. Latching + // in either window would burn the one auto-reveal this term gets. + if (isFindPlaceholder || isFindFetching) return + if (autoRevealedTermRef.current === submittedQuery) return + autoRevealedTermRef.current = submittedQuery setCurrentMatchIndex(0) - if (findMatches.length > 0) goToMatch(0) - }, [findMatches, goToMatch]) + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + const first = findMatches[0] + if (!first) return + if (!rowsRef.current.some((r) => r.id === first.rowId)) return + goToMatch(0) + }, [submittedQuery, findMatches, isFindPlaceholder, isFindFetching, goToMatch]) - const handleFindSubmit = useCallback(() => { - setSubmittedQuery(findQuery.trim()) - }, [findQuery]) + /** + * Step to the next/previous match — or, when the cursor is not on a match + * yet, to the current index itself. That second case is the term whose first + * hit the auto-reveal skipped because its row wasn't loaded: `+1` there would + * silently step over the very match the user pressed Enter to reach, and it + * would only come back around after wrapping the whole list. + */ + /** + * The index the next step counts from, clamped into the CURRENT match set. + * + * A row write or SSE update can shrink or reorder the matches for a term the + * user is still navigating; the term latch deliberately leaves the cursor + * alone in that case, so the stored index can now point past the end. Stepping + * from it would wrap off a stale base and land somewhere unrelated to the + * match on screen. Clamping here rather than in the two callers keeps the + * stepping base and the displayed index in agreement. + */ + const stepBaseIndex = () => + Math.min(currentMatchIndexRef.current, Math.max(0, findMatchesRef.current.length - 1)) const handleFindNext = useCallback(() => { - goToMatch(currentMatchIndexRef.current + 1) + if (!findResultsAreCurrentRef.current) return + const index = stepBaseIndex() + goToMatch(cursorIsOnMatchRef.current ? index + 1 : index) }, [goToMatch]) const handleFindPrev = useCallback(() => { - goToMatch(currentMatchIndexRef.current - 1) + if (!findResultsAreCurrentRef.current) return + const index = stepBaseIndex() + goToMatch(cursorIsOnMatchRef.current ? index - 1 : index) }, [goToMatch]) + /** + * Closes the bar and leaves no trace of the search: the term, the highlights + * (via the emptied term), and the match cursor all go. + * + * The cell selection is deliberately left where it is. Restoring the cell the + * user was on before opening find reads nicely, but deciding whether the + * current selection belongs to find or to the user is not answerable here — + * the grid has ~15 places that move the selection and no notion of who owns + * it, so every heuristic (compare the anchor, also check the focus, clear on + * click, clear on keydown) mis-fires on some ordinary gesture: extending a + * range from a match, clicking the match cell itself, arrowing away and back, + * Cmd+Z, or Cmd+F to refocus the bar. Leaving the cursor on the last match is + * what Sheets does and what this grid already did before find was reworked. + */ const handleFindClose = useCallback(() => { setFindOpen(false) setFindQuery('') - setSubmittedQuery('') + setCurrentMatchIndex(0) pendingMatchRef.current = null + // Strands any jump still paging toward a match, so it can't reveal a cell + // after the bar is gone. + goToMatchSeqRef.current++ + autoRevealedTermRef.current = '' + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + setIsJumping(false) scrollRef.current?.focus({ preventScroll: true }) }, []) + /** The grid's own Escape handler is bound once and closes find through the + * same path as the bar's Escape, so the two can't drift. */ + const handleFindCloseRef = useRef(handleFindClose) + handleFindCloseRef.current = handleFindClose + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. @@ -1763,54 +2048,176 @@ export function TableGrid({ ) }, []) - const handleColumnDragStart = useCallback((columnName: string) => { - setDragColumnName(columnName) - setSelectionAnchor(null) - setSelectionFocus(null) - setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) - setIsColumnSelection(false) + const stopColumnDragAutoScroll = useCallback(() => { + columnDragPointerXRef.current = null + if (columnDragScrollFrameRef.current !== null) { + cancelAnimationFrame(columnDragScrollFrameRef.current) + columnDragScrollFrameRef.current = null + } }, []) - const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { - const dragged = dragColumnNameRef.current - const cols = schemaColumnsRef.current - const targetCol = cols.find((c) => getColumnId(c) === columnName) - const targetGid = targetCol?.workflowGroupId + const handleColumnDragLeave = useCallback(() => { + dropTargetColumnNameRef.current = null + setDropTargetColumnName(null) + }, []) - // Suppress drop targeting while hovering siblings of the dragged column's - // own group: reordering inside a group is meaningless (the group renders - // as a unit) and the chasing indicator just flickers. - if (dragged) { + const updateColumnDropTarget = useCallback( + (columnName: string, side: 'left' | 'right') => { + const dragged = dragColumnNameRef.current + if (!dragged) return + + const cols = schemaColumnsRef.current const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId - if (draggedGid && draggedGid === targetGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) + const targetGid = cols.find((c) => getColumnId(c) === columnName)?.workflowGroupId + if ( + (draggedGid && draggedGid === targetGid) || + pinnedColumnsRef.current.includes(dragged) !== pinnedColumnsRef.current.includes(columnName) + ) { + handleColumnDragLeave() return } + + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return + dropTargetColumnNameRef.current = columnName + dropSideRef.current = side + setDropTargetColumnName(columnName) + setDropSide(side) + }, + [handleColumnDragLeave] + ) + + function updateColumnDropTargetAtX(pointerX: number) { + const thead = theadRef.current + const scrollEl = scrollRef.current + const headerRow = thead?.rows.item((thead?.rows.length ?? 0) - 1) + if (!thead || !scrollEl || !headerRow) { + handleColumnDragLeave() + return } - // Reorder is restricted to within a single zone so a cross-zone drop - // indicator never appears for an insertion the grid would refuse. - if (dragged) { - const pinned = pinnedColumnsRef.current - if (pinned.includes(dragged) !== pinned.includes(columnName)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return + const headerRowRect = headerRow.getBoundingClientRect() + const headerY = headerRowRect.top + headerRowRect.height / 2 + const hoveredElement = document.elementFromPoint(pointerX, headerY) + let header = hoveredElement?.closest('th[data-column-drag-target]') ?? null + if (!header || !headerRow.contains(header)) { + const scrollRect = scrollEl.getBoundingClientRect() + const pinnedRight = Math.min(scrollRect.right, scrollRect.left + pinnedStickyLeftEdge) + let nearestDistance = Number.POSITIVE_INFINITY + header = null + + for (const candidate of headerRow.querySelectorAll( + 'th[data-column-drag-target]' + )) { + const candidateName = candidate.dataset.columnDragTarget + if (!candidateName) continue + + const rect = candidate.getBoundingClientRect() + const isPinned = pinnedColumnsRef.current.includes(candidateName) + const left = Math.max(rect.left, isPinned ? scrollRect.left : pinnedRight) + const right = Math.min(rect.right, isPinned ? pinnedRight : scrollRect.right) + if (right <= left) continue + + const distance = pointerX < left ? left - pointerX : pointerX > right ? pointerX - right : 0 + if (distance < nearestDistance) { + nearestDistance = distance + header = candidate + } + } + } + + if (!header) { + handleColumnDragLeave() + return + } + + let columnName = header.dataset.columnDragTarget + if (!columnName) { + handleColumnDragLeave() + return + } + + const targetGroupId = header.dataset.columnDragGroup + let { left, right } = header.getBoundingClientRect() + if (targetGroupId) { + const targetColumn = columnsRef.current.find((column) => column.key === columnName) + const groupStart = targetColumn + ? columnsRef.current[targetColumn.groupStartColIndex] + : undefined + if (!groupStart || groupStart.workflowGroupId !== targetGroupId) { + throw new Error(`Missing rendered start column for workflow group ${targetGroupId}`) + } + columnName = groupStart.key + + const groupHeaders = thead.querySelectorAll('th[data-column-drag-group]') + for (const groupHeader of groupHeaders) { + if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue + const rect = groupHeader.getBoundingClientRect() + left = Math.min(left, rect.left) + right = Math.max(right, rect.right) } } - // Workflow groups: skip per-`` writes and let `handleScrollDragOver` - // do the bookkeeping. The scroll handler computes side from the group's - // full bounds, so it stays stable across sibling cursor moves; the per-th - // events would otherwise oscillate name + side as the cursor crosses each - // sibling's midpoint. - if (targetGid) return + updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right') + } - if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return - setDropTargetColumnName(columnName) - setDropSide(side) - }, []) + function startColumnDragAutoScroll(pointerX: number) { + columnDragPointerXRef.current = pointerX + if (columnDragScrollFrameRef.current !== null) return + + const tick = () => { + columnDragScrollFrameRef.current = null + const scrollEl = scrollRef.current + const currentPointerX = columnDragPointerXRef.current + if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return + + const scrollRect = scrollEl.getBoundingClientRect() + const velocity = horizontalEdgeScrollVelocity({ + pointerX: currentPointerX, + visibleLeft: scrollRect.left + pinnedStickyLeftEdge, + visibleRight: scrollRect.right, + hotZone: COLUMN_DRAG_SCROLL_HOT_ZONE_PX, + maxVelocity: COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX, + }) + if (velocity === 0) return + + const previousScrollLeft = scrollEl.scrollLeft + scrollEl.scrollLeft += velocity + if (scrollEl.scrollLeft !== previousScrollLeft) { + updateColumnDropTargetAtX(currentPointerX) + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + } + + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + + useEffect(() => stopColumnDragAutoScroll, [stopColumnDragAutoScroll]) + + const handleColumnDragStart = useCallback( + (columnName: string) => { + stopColumnDragAutoScroll() + dragColumnNameRef.current = columnName + setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) + setIsColumnSelection(false) + }, + [stopColumnDragAutoScroll] + ) + + const handleColumnDragOver = useCallback( + (columnName: string, side: 'left' | 'right') => { + const cols = schemaColumnsRef.current + const targetCol = cols.find((c) => getColumnId(c) === columnName) + if (targetCol?.workflowGroupId) return + updateColumnDropTarget(columnName, side) + }, + [updateColumnDropTarget] + ) const handleColumnDragEnd = useCallback(() => { + stopColumnDragAutoScroll() const dragged = dragColumnNameRef.current if (!dragged) { setDragColumnName(null) @@ -1945,64 +2352,27 @@ export function TableGrid({ setDragColumnName(null) setDropTargetColumnName(null) setDropSide('left') - }, []) - - const handleColumnDragLeave = useCallback(() => { - dropTargetColumnNameRef.current = null - setDropTargetColumnName(null) - }, []) + }, [stopColumnDragAutoScroll]) function handleScrollDragOver(e: React.DragEvent) { - if (!dragColumnNameRef.current) return + const draggedName = dragColumnNameRef.current + if (!draggedName) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const scrollEl = scrollRef.current if (!scrollEl) return - const scrollRect = scrollEl.getBoundingClientRect() - const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft - - const cols = columnsRef.current - const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId - let left = checkboxColWidth - let i = 0 - while (i < cols.length) { - const col = cols[i] - // Treat fanned-out groups as monolithic drop targets; accumulate across siblings. - // Clamp `groupSize` to remaining columns: dragover fires constantly and can - // race a column removal where the cached `groupSize` outpaces `cols.length`. - const groupSize = Math.min(col.groupSize, cols.length - i) - let groupWidth = 0 - for (let j = 0; j < groupSize; j++) { - groupWidth += columnWidthsRef.current[cols[i + j].key] ?? COL_WIDTH - } - if (cursorX < left + groupWidth) { - // Inside the dragged column's own group → no-op drop, no indicator. - if (draggedGid && col.workflowGroupId === draggedGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const pinned = pinnedColumnsRef.current - const draggedName = dragColumnNameRef.current - if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const midX = left + groupWidth / 2 - const side = cursorX < midX ? 'left' : 'right' - if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { - setDropTargetColumnName(col.key) - setDropSide(side) - } - return - } - left += groupWidth - i += groupSize + if (pinnedColumnsRef.current.includes(draggedName)) { + stopColumnDragAutoScroll() + } else { + startColumnDragAutoScroll(e.clientX) } + updateColumnDropTargetAtX(e.clientX) } function handleScrollDrop(e: React.DragEvent) { e.preventDefault() + stopColumnDragAutoScroll() } useEffect(() => { @@ -2476,10 +2846,7 @@ export function TableGrid({ if (e.key === 'Escape') { e.preventDefault() if (findOpenRef.current) { - setFindOpen(false) - setFindQuery('') - setSubmittedQuery('') - pendingMatchRef.current = null + handleFindCloseRef.current() return } if (dragColumnNameRef.current) { @@ -4228,15 +4595,19 @@ export function TableGrid({ )} @@ -4275,7 +4646,12 @@ export function TableGrid({ {headerGroups.map((g) => { const firstCol = displayColumns[g.startColIndex] - const stickyLeft = firstCol ? pinnedOffsets.get(firstCol.key) : undefined + if (!firstCol) { + throw new Error( + `Missing display column for header group at index ${g.startColIndex}` + ) + } + const stickyLeft = pinnedOffsets.get(firstCol.key) if (g.kind === 'workflow') { const lastCol = displayColumns[g.startColIndex + g.size - 1] return ( @@ -4284,7 +4660,8 @@ export function TableGrid({ workflowId={g.workflowId} size={g.size} startColIndex={g.startColIndex} - columnName={firstCol?.name ?? ''} + columnName={firstCol.name} + columnKey={firstCol.key} column={firstCol} workflows={workflows} isGroupSelected={ @@ -4353,17 +4730,18 @@ export function TableGrid({ onDragLeave={ userPermissions.canEdit ? handleColumnDragLeave : undefined } - isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false} + isPinned={pinnedColumnSet.has(firstCol.key)} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={stickyLeft} isLastPinned={lastCol?.key === lastPinnedColKey} /> ) } - const isLastFrz = firstCol?.key === lastPinnedColKey + const isLastFrz = firstCol.key === lastPinnedColKey return ( 0 ? pinnedOffsets : undefined} lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 0dc6c3cd9b2..80534939ca4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -13,6 +13,7 @@ import { canWriteRowsWithChip, chipRowCount, drainTargetForChip, + horizontalEdgeScrollVelocity, selectedColumnIds, } from './utils' @@ -25,6 +26,55 @@ function columns(count: number): DisplayColumn[] { const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`) +describe('horizontalEdgeScrollVelocity', () => { + const getVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 140, + visibleRight: 900, + hotZone: 48, + maxVelocity: 14, + }) + + it('scrolls left when the pointer enters the visible edge after sticky columns', () => { + expect(getVelocity(140)).toBe(-14) + expect(getVelocity(164)).toBe(-7) + }) + + it('scrolls right at the opposite edge and stays still between edge zones', () => { + expect(getVelocity(876)).toBe(7) + expect(getVelocity(900)).toBe(14) + expect(getVelocity(500)).toBe(0) + }) + + it('stays still when pinned columns consume the visible viewport', () => { + expect( + horizontalEdgeScrollVelocity({ + pointerX: 100, + visibleLeft: 200, + visibleRight: 100, + hotZone: 48, + maxVelocity: 14, + }) + ).toBe(0) + }) + + it('uses the nearest edge when a narrow viewport would overlap both hot zones', () => { + const narrowVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 100, + visibleRight: 140, + hotZone: 48, + maxVelocity: 14, + }) + + expect(narrowVelocity(105)).toBe(-11) + expect(narrowVelocity(120)).toBe(0) + expect(narrowVelocity(135)).toBe(11) + }) +}) + describe('selectedColumnIds', () => { it('returns the ids the range spans', () => { expect(selectedColumnIds(columns(5), { startCol: 1, endCol: 3 })).toEqual(['c1', 'c2', 'c3']) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index b2486fcf969..4f3e9282d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -31,6 +31,43 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +interface HorizontalEdgeScrollVelocityInput { + pointerX: number + visibleLeft: number + visibleRight: number + hotZone: number + maxVelocity: number +} + +export function horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft, + visibleRight, + hotZone, + maxVelocity, +}: HorizontalEdgeScrollVelocityInput): number { + if (hotZone <= 0) throw new Error('hotZone must be greater than zero') + if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero') + const visibleWidth = visibleRight - visibleLeft + if (visibleWidth <= 0) return 0 + + const edgeZone = Math.min(hotZone, visibleWidth / 2) + + const distanceFromLeft = pointerX - visibleLeft + if (distanceFromLeft < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone + return -Math.ceil(intensity * maxVelocity) + } + + const distanceFromRight = visibleRight - pointerX + if (distanceFromRight < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone + return Math.ceil(intensity * maxVelocity) + } + + return 0 +} + export function rowSelectionIncludes(sel: RowSelection, id: string): boolean { if (sel.kind === 'all') return !sel.excluded?.has(id) if (sel.kind === 'some') return sel.ids.has(id) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 5b510dc38e3..dd6776ded92 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -17,6 +17,15 @@ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' * recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column * operator objects); serializing it would put a large structured blob in the * URL, which the URL-state doctrine forbids. It stays in local `useState`. + * + * The in-grid `find` (Cmd+F) is likewise absent, for a different reason: it is + * a viewport cursor, not a destination. Two things rule it out. It is not one + * value but a cluster — the term, the match cursor, and the cell the user was + * on before opening find — and only the term is serializable; closing restores + * that pre-find cell from an in-memory ref, so a term that survived a reload + * would arrive with no origin to return to. And the search runs on every + * debounced keystroke rather than on submit, which is the write frequency this + * doctrine keeps out of the URL. Same call the browser's own Cmd+F makes. */ export const tableDetailParsers = { sort: parseAsString, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index cb2f0fdd6b7..dc5e89a8742 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -9,7 +9,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -22,9 +22,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +38,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +48,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -64,6 +72,8 @@ import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hoo import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { exportTable, + useBulkDeleteTables, + useBulkMoveTables, useCreateTable, useDeleteTable, useImportCsv, @@ -92,6 +102,10 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +/** This list's private drag MIME, so a drag started on another list is never mistaken for one + * of these rows. */ +const TABLE_ROW_DRAG_MIME = 'application/x-sim-workspace-table-rows' + /** Root label for breadcrumbs and the "move to workspace root" destination. */ const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel @@ -154,6 +168,8 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) + const bulkMoveTables = useBulkMoveTables(workspaceId) + const bulkDeleteTables = useBulkDeleteTables(workspaceId) const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -203,6 +219,7 @@ export function Tables() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false) + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) const [activeFolder, setActiveFolder] = useState(null) @@ -241,6 +258,20 @@ export function Tables() { const uploading = uploadProgress.total > 0 const csvInputRef = useRef(null) + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const tableById = useMemo(() => { + const byId = new Map() + for (const table of tables) byId.set(table.id, table) + return byId + }, [tables]) + const tableByIdRef = useRef(tableById) + tableByIdRef.current = tableById + const tablesRef = useRef(tables) tablesRef.current = tables @@ -258,7 +289,8 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() - const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table') + /** Which row kind the row context menu acts on — whichever active slot the handler filled. */ + const contextMenuKind: 'table' | 'folder' = activeFolder ? 'folder' : 'table' /** * Descendants of every folder, so a move destination that sits inside the moved folder can @@ -459,6 +491,41 @@ export function Tables() { [listRename.startRename] ) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isDeleteDialogOpen || isDeleteFolderDialogOpen || isBulkDeleteDialogOpen || isImportDialogOpen + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => !canEdit || listRename.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const { folderIds: selectedFolderIds, resourceIds: selectedTableIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedTableIds.length + selectedFolderIds.length + const firstName = + selectedTableIds.length > 0 + ? tables.find((table) => table.id === selectedTableIds[0])?.name + : folderById.get(selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedTableIds, selectedFolderIds, tables, folderById]) + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { if (!currentFolderId) return undefined const folder = folderById.get(currentFolderId) @@ -570,18 +637,7 @@ export function Tables() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -592,7 +648,7 @@ export function Tables() { () => (
- Row Count + Row Count {memberOptions.length > 0 && (
- Owner + Owner { const item = resolveRowItem(rowId) if (!item) return + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEdit && !selectedRowIds.has(rowId)) replaceSelection([rowId]) if (item.kind === 'folder') { setActiveFolder(item.folder) setActiveTable(null) - setContextMenuKind('folder') } else { setActiveTable(item.table) setActiveFolder(null) - setContextMenuKind('table') } handleRowCtxMenu(e) }, - [resolveRowItem, handleRowCtxMenu] + [resolveRowItem, handleRowCtxMenu, canEdit, selectedRowIds, replaceSelection] ) + /** Move targets for a table: every folder, since a table has no subtree. */ const tableMoveOptions: MoveOptionNode[] = useMemo( () => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }), [folders] ) - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded }) - }, [activeFolder, folders, descendantFolderIds]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId: descendantFolderIds, + }) + : [], + [activeFolder, folders, descendantFolderIds] + ) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIds, + }), + [selectedFolderIds, folders, descendantFolderIds] + ) const handleMoveTable = useCallback( (optionValue: string) => { @@ -753,7 +835,7 @@ export function Tables() { * Placement is re-read from the live list rather than trusted from `activeTable`, which * is a snapshot taken when the menu opened. A refetch or a concurrent move since then * would make the no-op check compare against a stale location and skip a write the user - * asked for. Matches the knowledge-base move. + * asked for. */ const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable if ((current.folderId ?? null) === folderId) { @@ -794,22 +876,135 @@ export function Tables() { [activeFolder, folderById, moveFolderTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of tables and folders + * commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { tableIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.tableIds.length === 0 && rows.folderIds.length === 0) return + if (rows.tableIds.length + rows.folderIds.length > MAX_TABLE_BATCH_ITEMS) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveTables.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { tableIds: selectedTableIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedTableIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = selectedTableIds.length + selectedFolderIds.length > MAX_TABLE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedTableIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteDialogOpen(true) + }, [selectedTableIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteTables.mutateAsync({ + tableIds: selectedTableIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteDialogOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (err) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items:', err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedTableIds, selectedFolderIds, clearSelection]) + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * these handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : tableMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleMoveTableFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveTable(optionValue) + }, + [handleBulkMove, handleMoveTable] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + + const handleDeleteTableFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteDialogOpen(true) + }, [handleBulkDelete]) + + const handleDeleteFolderFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteFolderDialogOpen(true) + }, [handleBulkDelete]) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: TABLE_ROW_DRAG_MIME, canEdit, editingRowId: listRename.editingId, descendantsByFolderId: descendantFolderIds, getFolderParentId: (folderId) => folderById.get(folderId)?.parentId ?? null, - getResourceFolderId: (tableId) => - tablesRef.current.find((table) => table.id === tableId)?.folderId ?? null, + getResourceFolderId: (tableId) => tableByIdRef.current.get(tableId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' ? (folderById.get(parsed.id)?.name ?? 'Folder') - : (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table') + : (tableByIdRef.current.get(parsed.id)?.name ?? 'Table') }, - onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId), - onMoveResource: (tableId, targetFolderId) => - moveTable.mutate({ tableId, folderId: targetFolderId }), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, tableIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const handleDelete = async () => { @@ -1027,9 +1222,31 @@ export function Tables() { ] ) - // Stable identities so the memoized Resource.Header / Resource.Options can + // Stable identities so the memoized Resource.Header / Resource.Options / Resource.Table can // actually bail — inline object/element props would defeat their memo. const headerAside = useMemo(() => , [workspaceId]) + + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveTables.isPending, + bulkDeleteTables.isPending, + ] + ) const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) return ( @@ -1041,6 +1258,7 @@ export function Tables() { breadcrumbs={breadcrumbs} actions={headerActions} aside={headerAside} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1086,7 +1306,7 @@ export function Tables() { onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} - onDelete={() => setIsDeleteDialogOpen(true)} + onDelete={handleDeleteTableFromMenu} onRename={() => { if (activeTable) listRename.startRename(activeTable.id, activeTable.name) }} @@ -1103,8 +1323,8 @@ export function Tables() { }} onTogglePin={handleTogglePin} pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} - onMove={canEdit ? handleMoveTable : undefined} - moveOptions={canEdit ? tableMoveOptions : undefined} + onMove={canEdit ? handleMoveTableFromMenu : undefined} + moveOptions={canEdit ? activeMoveOptions : undefined} disableDelete={!canEdit} disableRename={!canEdit} disableImport={!canEdit} @@ -1124,11 +1344,11 @@ export function Tables() { onCopyId={() => { if (activeFolder) navigator.clipboard.writeText(activeFolder.id) }} - onDelete={() => setIsDeleteFolderDialogOpen(true)} + onDelete={handleDeleteFolderFromMenu} onTogglePin={handleTogglePin} pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false} - onMove={canEdit ? handleMoveFolder : undefined} - moveOptions={canEdit ? folderMoveOptions : undefined} + onMove={canEdit ? handleMoveFolderFromMenu : undefined} + moveOptions={canEdit ? activeFolderMoveOptions : undefined} canEdit={canEdit} /> @@ -1189,6 +1409,32 @@ export function Tables() { pendingLabel: 'Deleting...', }} /> + + 0 + ? 'Every table and subfolder inside the selected folders will be deleted too.' + : 'All of their rows will be removed.', + error: true, + }, + ' You can restore those tables from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteTables.isPending, + pendingLabel: 'Deleting...', + }} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx new file mode 100644 index 00000000000..fe74a0467e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx @@ -0,0 +1,256 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +/** jsdom ships no ResizeObserver; the editor observes its container to size the gutter. */ +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver + +const { SECRET, searchTargetRef } = vi.hoisted(() => ({ + SECRET: 'SIM-TEST-CREDENTIAL-MARKER\nfixture-body-abc123\nend-of-fixture', + searchTargetRef: { current: null as Record | null }, +})) + +vi.mock('@sim/emcn', () => ({ + CODE_LINE_HEIGHT_PX: 21, + Code: { + Container: ({ children }: { children: ReactNode }) =>
{children}
, + Gutter: ({ children }: { children: ReactNode }) =>
{children}
, + Content: ({ + children, + editorRef, + }: { + children: ReactNode + editorRef?: React.RefObject + }) =>
{children}
, + Placeholder: ({ children, show }: { children: ReactNode; show: boolean }) => + show ?
{children}
: null, + }, + calculateGutterWidth: () => 24, + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + Duplicate: () => null, + getCodeEditorProps: () => ({}), + highlight: (code: string) => code, + languages: { javascript: {}, python: {}, bash: {} }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + Check: () => null, + Wand: () => null, +})) + +vi.mock('react-simple-code-editor', () => ({ + default: ({ + value, + highlight, + onFocus, + onBlur, + }: { + value: string + highlight: (code: string) => string + onFocus: () => void + onBlur: () => void + }) => ( + <> +