diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 3f8c86f5fc7..863d7283329 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -293,7 +293,7 @@ path). A subblock whose meaning changes based on what the string looks like is i 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 +```typescript illustrative // ✓ 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' }, @@ -377,7 +377,7 @@ inputs: { For multiple file uploads: -```typescript +```typescript illustrative { id: 'attachments', title: 'Attachments', @@ -660,7 +660,7 @@ outputs: { When creating V2 blocks (alongside legacy V1): -```typescript +```typescript illustrative // V1 Block - mark as legacy export const ServiceBlock: BlockConfig = { type: 'service', @@ -700,7 +700,7 @@ export const ServiceV2Block: BlockConfig = { Register the block in `apps/sim/blocks/registry-maps.ts` — add the import and an entry to each map alphabetically: -```typescript +```typescript illustrative import { ServiceBlock, ServiceBlockMeta } from '@/blocks/blocks/{service}' export const BLOCK_REGISTRY: Record = { @@ -716,7 +716,7 @@ export const BLOCK_META_REGISTRY: Record = { ## Complete Example -```typescript +```typescript illustrative import { ServiceIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' @@ -814,7 +814,7 @@ export const ServiceBlock: BlockConfig = { If the service supports webhooks, connect the block to its triggers. -```typescript +```typescript illustrative import { getTrigger } from '@/triggers' export const ServiceBlock: BlockConfig = { @@ -963,7 +963,9 @@ But if the same change also adds, edits **or removes** a tool, run `bun run gene A visible integration block does require the generated integration catalog and docs to be refreshed: `bun run generate:tool-metadata` (only when a tool changed), `bun run scripts/generate-docs.ts`, -`bun run generate:deployment-config`, then `bun run check:audits`. Also run +`bun run generate:deployment-config`, `bun scripts/sync-platform-integration-metadata.ts` (the +Platform integration catalog, checked by `check:platform-integration-metadata`), then +`bun run check:audits`. Also run `bun run apps/sim/scripts/check-block-registry.ts origin/staging` (CI runs it outside `check:audits`). Commit the full generator output. For what each check verifies, see the `validate-integration` skill → Regenerate Derived Artifacts. @@ -989,6 +991,7 @@ Regenerate Derived Artifacts. - [ ] If any tool was added, changed or removed alongside the block: ran `bun run generate:tool-metadata` and committed the artifacts - [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes - [ ] `bun run check:integration-catalog` passes +- [ ] Ran `bun scripts/sync-platform-integration-metadata.ts` and committed its output; `bun run check:platform-integration-metadata` passes - [ ] `bun run check:docs` 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 @@ -1028,7 +1031,7 @@ A sub-block gets its choices from exactly one of two places. There is no third. `apps/sim/lib/selectors/server/internal.ts`. Point the sub-block at that key. All remote selectors execute through `selectors.execute`; never add a client provider module or selector-only fetch route. -```ts +```ts illustrative { id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' }, { id: 'labelIds', type: 'dropdown', multiSelect: true, selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' }, @@ -1045,7 +1048,7 @@ legacy source id must be retained. **`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. -```ts +```ts illustrative options: (params) => { const model = params?.values.model return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS diff --git a/.agents/skills/add-column-type/SKILL.md b/.agents/skills/add-column-type/SKILL.md index d46f1d0ec7d..6b7e9ae7b15 100644 --- a/.agents/skills/add-column-type/SKILL.md +++ b/.agents/skills/add-column-type/SKILL.md @@ -50,7 +50,7 @@ Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drive ## Step 2: Add the icon -Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: +Create `packages/emcn-legacy/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: ```tsx import type { SVGProps } from 'react' @@ -82,7 +82,7 @@ export function Type{Pascal}(props: SVGProps) { - `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`. +- Export alphabetically **by component name** in `packages/emcn-legacy/src/icons/index.ts`. ## Step 3: Write the type file @@ -118,7 +118,7 @@ Prefer set-based SQL. When the transform genuinely needs JS (`currency`'s separa - **Import cycles.** `column-types/select.ts` imports `lib/table/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`.** Dozens of server modules import that barrel; routing this through it pulls `@sim/emcn/icons` into all of them. Deep-import `@/lib/table/column-types`. +- **Don't re-export the registry from `@/lib/table`.** Dozens of server modules import that barrel; routing this through it pulls `@sim/emcn-legacy/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). @@ -134,7 +134,7 @@ Registering the *type* is compiler-enforced. Registering its *metadata* is not, | `lib/api/contracts/v2/tables.ts` and `lib/table/application/columns.ts` — the same slots for the v2 API and its use cases | the v2 API silently drops it | | `columns/service.ts` `addTableColumn` param type | callers cannot pass it | | A metadata-only update in `lib/table/columns/service.ts` (`updateColumnCurrency` is the model) + a branch in `performUpdateTableColumn` in `lib/table/orchestration/columns.ts` | changing it on an existing column is a silent 200 no-op | -| `column-config-sidebar.tsx` | no UI to set it | +| `column-config.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. @@ -156,6 +156,6 @@ Copy `currency`'s service function and orchestration branch. 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** — `bun run --cwd apps/sim test lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. +3. **Run the suite** — `bun run --cwd apps/sim test lib/table components/tables lib/api app/api/table app/api/v1/tables app/api/v2/tables`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. 4. **`bun run lint`, `bun run check:api-validation:strict`, `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/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 3631f5c3961..af7e09d8d41 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -400,7 +400,7 @@ contentHash: `confluence:${page.id}:${page.version.number}` ### Implementation Pattern -```typescript +```typescript illustrative // 1. Create a stub function (sync, no API calls) function fileToStub(file: ServiceFile): ExternalDocument { return { diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md index 8ac7f58a95c..56e12d5ad0d 100644 --- a/.agents/skills/add-enrichment/SKILL.md +++ b/.agents/skills/add-enrichment/SKILL.md @@ -64,8 +64,8 @@ Why it matters: the cascade runner only bills (and only reads `output.cost.total Create `apps/sim/enrichments/{id}/{id}.ts` and a barrel `index.ts`. Mirror the entries registered in `enrichments/registry.ts`. -```typescript -import { SomeIcon } from '@sim/emcn/icons' +```typescript illustrative +import { SomeIcon } from '@sim/emcn-legacy/icons' import { filterUndefined } from '@sim/utils/object' import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' import type { EnrichmentConfig } from '@/enrichments/types' @@ -105,13 +105,13 @@ export const myEnrichment: EnrichmentConfig = { } ``` -```typescript +```typescript illustrative // apps/sim/enrichments/{id}/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. +- Keep the file **client-safe**: import only `@sim/emcn-legacy/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). @@ -119,7 +119,7 @@ Rules: In `apps/sim/enrichments/registry.ts`, import and add the entry (catalog order is registration order): -```typescript +```typescript illustrative import { myEnrichment } from '@/enrichments/{id}' export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { diff --git a/.agents/skills/add-feature-flag/SKILL.md b/.agents/skills/add-feature-flag/SKILL.md index a5e1abf0e41..9d94f400228 100644 --- a/.agents/skills/add-feature-flag/SKILL.md +++ b/.agents/skills/add-feature-flag/SKILL.md @@ -43,7 +43,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi 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 + ```ts illustrative const FEATURE_FLAGS = { '': { description: '', diff --git a/.agents/skills/add-hosted-key/SKILL.md b/.agents/skills/add-hosted-key/SKILL.md index 773e9e2fa76..8696228916e 100644 --- a/.agents/skills/add-hosted-key/SKILL.md +++ b/.agents/skills/add-hosted-key/SKILL.md @@ -16,7 +16,7 @@ When a tool has hosted key support, Sim provides its own API key if the user has | 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`) | +| 5 | Add to BYOK settings UI | BYOK settings `providers.ts` | | 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) | ## Step 1: Register the BYOK Provider ID @@ -80,7 +80,7 @@ When choosing values for `requestsPerMinute` and any dimension limits: 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 +```typescript illustrative hosting: { envKeyPrefix: 'YOUR_SERVICE_API_KEY', apiKeyParam: 'apiKey', @@ -107,14 +107,14 @@ hosting: { Keys use a numbered naming pattern driven by a count env var: -``` +```text illustrative 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. +The `envKeyPrefix` value (`_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 @@ -187,7 +187,7 @@ transformResponse: async (response: Response) => { For async/polling tools, capture it in `postProcess` when the job completes: -```typescript +```typescript illustrative if (jobData.status === 'completed') { result.output = { data: jobData.data, @@ -253,9 +253,9 @@ To exclude multiple operations, use an array: `{ field: 'operation', value: ['op ## 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`: +Add an entry to the `PROVIDERS` array in the BYOK settings `providers.ts` so users can bring their own key. You need the service icon from `components/icons.tsx`: -```typescript +```typescript illustrative { id: 'your_service', name: 'Your Service', diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 71aa71c8b2c..a84458a140c 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -323,7 +323,7 @@ export const BLOCK_META_REGISTRY: Record = { ### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist -```typescript +```typescript illustrative // Add import (alphabetically) import { {service}EventATrigger, @@ -387,7 +387,9 @@ a resolvable capability must fail validation; an independent service account use ## Step 8: Generate and Validate the Catalog Run `bun run generate:tool-metadata`, `bun run scripts/generate-docs.ts`, -`bun run generate:deployment-config`, then `bun run check:audits` (see the `validate-integration` +`bun run generate:deployment-config`, `bun scripts/sync-platform-integration-metadata.ts` (the +Platform integration catalog; `check:platform-integration-metadata` is in `check:audits`), then +`bun run check:audits` (see the `validate-integration` skill → Regenerate Derived Artifacts for the full list and what each check verifies). The docs generator creates `apps/docs/content/docs/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`). @@ -616,7 +618,7 @@ tools: { #### 3. Define and register the in-process operation -```typescript +```typescript illustrative export const {service}UploadTool: InternalToolConfig = { id: '{service}_upload', // ... diff --git a/.agents/skills/add-managed-cli/SKILL.md b/.agents/skills/add-managed-cli/SKILL.md index 325dae43e31..6c3dfd12936 100644 --- a/.agents/skills/add-managed-cli/SKILL.md +++ b/.agents/skills/add-managed-cli/SKILL.md @@ -116,7 +116,7 @@ bun run --cwd apps/sim test \ lib/execution/remote-sandbox/cli-tools-boundary.test.ts \ lib/execution/remote-sandbox/spec.test.ts \ lib/execution/remote-sandbox/resolve.test.ts \ - 'app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts' + 'components/settings/sections/sandboxes/utils.test.ts' ``` From the repository root: diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index 5bcd08ff94c..81f324b812a 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -27,7 +27,7 @@ You are adding one governed item an organization admin can withhold from a cohor | Kind | Builder | Default | Semantics | |---|---|---|---| -| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Name it `hideX` / `disableX`, never `allowX` | +| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Name it `hide` / `disable`, never `allow` | | Allowlist | `allowlist(item, enforcement, { limited, empty })` | `null` | `null` allows everything; a list names the only permitted members; `[]` permits **none** | | Denylist | `denylist(item, enforcement, phrasing)` | `[]` | Empty permits everything; members are refused | @@ -49,7 +49,7 @@ Allowlist when the safe posture is "only what the admin named" and the member se ## Step 1: Append the field entry — never insert -```ts +```ts illustrative disableWidgetSharing: booleanRestriction('capability', { id: 'disable-widget-sharing', label: 'Widget Sharing', @@ -61,8 +61,8 @@ Allowlist when the safe posture is "only what the admin named" and the member se The second argument is the field's `feature` (`PlatformFeatureMeta`); `PLATFORM_FEATURES` spreads it and appends `configKey`, so those four values are what the editor renders. `PLATFORM_FEATURES` is *derived* from the registry in `features.ts`, so a boolean key cannot reach the config without reaching the editor. - **Declaration order is the wire order** of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API, since all of them derive from the registry. Extend the tail; do not tidy the middle. -- **The default must be the permissive value.** Every stored `permission_group.config` row predates your key; `parsePermissionGroupConfig` fills the gap from the default and the update route merges a partial write over the stored config, so a restrictive default silently applies a new restriction to every existing group in every enterprise org. The builders hardcode `false` / `null` / `[]`, so a new key must be *phrased* so the permissive value is falsy: a `requireWidgetApproval` whose safe default is `true` must be inverted before it can use `booleanRestriction`. -- **The checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*, so an `allowX` name renders backwards. +- **The default must be the permissive value.** Every stored `permission_group.config` row predates your key; `parsePermissionGroupConfig` fills the gap from the default and the update route merges a partial write over the stored config, so a restrictive default silently applies a new restriction to every existing group in every enterprise org. The builders hardcode `false` / `null` / `[]`, so a new key must be *phrased* so the permissive value is falsy: a `requireApproval` whose safe default is `true` must be inverted before it can use `booleanRestriction`. +- **The checkbox is inverted.** A feature key that is `true` reads as denied (`directlyDenied` in `ee/access-requests/lib/targets.ts` returns `config[configKey]`), so the editor shows it allowed only when false, and an `allow` name renders backwards. - **The hint must describe access withheld, never a surface hidden.** A `'capability'` key refuses at the API; "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar while they revoke a module. The same string is read again by `getActivePermissionGroupRestrictions` in `features.ts` as the prose for an *active* restriction — reaching users through the Copilot workspace VFS and the enterprise platform context — where "hide" is simply false. Write "Revoke the Tables module. Members cannot read or write any table." `PlatformFeatureMeta.hint` carries the rule in its TSDoc. - **The category must be in `PLATFORM_CATEGORY_ORDER`** (`features.ts`): `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category renders last. Categories name what is withheld — no surface-shaped section like "Sidebar". @@ -70,20 +70,17 @@ The second argument is the field's `feature` (`PlatformFeatureMeta`); `PLATFORM_ `PLATFORM_FEATURES` filters on `field.kind === 'boolean-restriction'`. An allowlist or denylist renders **nothing** — the key exists, the API accepts it, no admin can set it. -Nested pickers hang off the `featureExtras` map in `group-detail.tsx`, keyed by the **feature id of the boolean it nests under**, not the allowlist's own config key: +Nested pickers are permission grids on the boolean's row. `FEATURE_PERMISSION_GRIDS` in `ee/access-control/lib/editor.ts` maps the **feature id of the boolean it nests under** (not the allowlist's own config key) to a grid kind, and `group-detail.tsx` renders that kind as a `PermissionGrid`: ```ts - const featureExtras: Partial> = { - 'hide-knowledge-base': , - } +export const FEATURE_PERMISSION_GRIDS = { + 'hide-deploy-chatbot': 'chat_deploy_auth', + 'disable-public-file-sharing': 'file_share_auth', + 'hide-knowledge-base': 'knowledge_connector', +} as const ``` -Copy `setKnowledgeConnectors`. Two load-bearing behaviors: - -- **Refuse an empty selection** (`if (values.length === 0) return`) — an emptied allowlist denies everyone while the parent checkbox still reads as allowed. Withholding the whole thing is what the parent is for. -- **Collapse "all selected" back to `null`** (`values.length === ALL.length ? null : values`) — storing the full set freezes the allowlist at today's members. +A new grid kind also needs its arm in `withPermissionGroupAccessState` (`ee/access-control/utils/access-state.ts`), which writes the config key. Copy the `knowledge_connector` arm: its `setMember` collapses "all selected" back to `null`, because storing the full set freezes the allowlist at today's members. Choose the parent deliberately: `allowedKnowledgeConnectors` nests under `hide-knowledge-base`, not `disable-knowledge-base-creation`, because a connector attaches to an *existing* KB — nesting under creation would dim the picker for exactly the cohort it serves. @@ -95,7 +92,7 @@ Skip only for `'executor'` / `'ui-only'`. Add the id to `CAPABILITY_IDS` and the Capability ids are **domain-shaped** (`tables.create`); config keys are **surface-shaped** (`disableTableCreation`). `CAPABILITY_RULES` is the only place the two vocabularies meet. -```ts +```ts illustrative 'widgets.share': { kind: 'static', configKeys: ['disableWidgetSharing'], @@ -259,7 +256,7 @@ What a run *does* is still governed by `assertPermissionsAllowed`. An item that - [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name - [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld - [ ] `hint` says what access is revoked, never "hide" — it is also the active-restriction prose -- [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` +- [ ] Nested non-boolean key has a `FEATURE_PERMISSION_GRIDS` entry and an access-state arm that collapses "all" to `null` - [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES` under `satisfies`, `configKeys` lists every key `deniedBy` reads - [ ] A narrower capability replacing a broader one also reads the broader key - [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation raising through `refuseCapability` / `capabilityRefusal` diff --git a/.agents/skills/add-settings-page/SKILL.md b/.agents/skills/add-settings-page/SKILL.md index 94ff0f182e7..680836e1d61 100644 --- a/.agents/skills/add-settings-page/SKILL.md +++ b/.agents/skills/add-settings-page/SKILL.md @@ -1,42 +1,53 @@ --- name: add-settings-page -description: Add a new Sim settings page, or audit existing settings pages for design-system compliance with the shared SettingsPanel layout. Use when creating a settings tab, or when asked to check/clean up settings pages so they match the design system (consistent title, header, search, spacing). +description: Add or audit the account and self-hosted settings pages that use SettingsPanel. Use implement-platform for Platform organization and workspace Settings. --- # Settings Page (add / audit) +For Platform Settings (`apps/sim/app/(product)/**`, `apps/sim/platform/**`), use [implement-platform](../implement-platform/SKILL.md). In a mixed request, apply the steps below only to the account and self-hosted settings. + Settings page chrome (header bar, scroll region, content column, nav-driven -title + description) is owned by the `settings/[section]/layout.tsx` shell. Each +title + description) is owned by `StandaloneSettingsShell`, which the account and +self-hosted `settings/layout.tsx` mount. Each page renders through **`SettingsPanel`**, which registers the page's header data (actions, search, back) with that shell and renders only the body. The full convention lives in `.claude/rules/sim-settings-pages.md` — read it first; this skill is the procedure. Key paths: -- Chrome shell: `apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx` (`SettingsHeaderShell`) +- Chrome shell: `apps/sim/components/settings/standalone-settings-shell.tsx` (`SettingsHeaderShell` from `settings-header.tsx`), mounted by `apps/sim/app/(site)/account/settings/layout.tsx` and `apps/sim/app/(site)/selfhost/settings/layout.tsx` - `SettingsPanel` registrar: `apps/sim/components/settings/settings-panel.tsx` - Nav metadata (titles + descriptions): `apps/sim/components/settings/navigation.ts` -- Section switch + provider: `apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx` -- Pages: `apps/sim/app/workspace/[workspaceId]/settings/components//.tsx` and EE pages under `apps/sim/ee//components/` +- Section switch: `apps/sim/components/settings/account-settings-renderer.tsx` and `selfhost-settings-renderer.tsx` +- Pages: `apps/sim/components/settings/sections//.tsx` and EE pages under `apps/sim/ee//components/` ## Mode A — Add a new settings page -1. **Navigation.** In `navigation.ts`: add the id to `UnifiedSettingsSection`, then a +1. **Navigation.** In `navigation.ts`: add the id to the plane's section union + (`AccountSettingsSection` or `SelfHostSettingsSection`), then a `SETTINGS_SECTION_REGISTRY` entry with `label`, `icon`, and - `unified: { id, description, group, order }` (description verb-first, ~40–55 chars, - product voice per `.claude/rules/constitution.md`). Set gating flags - (`requiresHosted`, `requiresEnterprise`, …) on `unified`, and add `planes` only if the - section also exists on a standalone plane. -2. **Wire the switch.** Register the module in `SECTION_MODULES` - (`settings/section-warmers.ts`), then add - `const X = dynamic(() => SECTION_MODULES.x().then((m) => m.X))` and its case in the - `effectiveSection` switch in `settings/[section]/settings.tsx`. + `planes: { account: { id, group, order, description } }` (or `selfhost:`). The account + and self-hosted sidebars, titles and route parsing read only these plane projections + (`ACCOUNT_SETTINGS_ITEMS`, `SELFHOST_SETTINGS_ITEMS`, `getSettingsSectionMeta`); `unified` + feeds no account or self-hosted surface. Description: verb-first, ~40–55 chars, product + voice per `.claude/rules/constitution.md`. +2. **Wire the switch.** In the plane's renderer (`account-settings-renderer.tsx` or + `selfhost-settings-renderer.tsx`), add + `const X = dynamic(() => import('@/components/settings/sections//').then((m) => m.X))` + and its branch, and gate it in that plane's `[section]/page.tsx` if needed. 3. **Build the body inside `SettingsPanel`** per the rule's canonical page shape: `actions`, `search`, `children`, modal siblings in a fragment. 4. **If the page has editable state**, wire the shared save/discard stack exactly as in `.claude/rules/sim-settings-pages.md` "Save / Discard + unsaved-changes guard" - (`saveDiscardActions()`, `useSettingsUnsavedGuard` before any early return). -5. **Verify:** the local gate in the root `CLAUDE.md` ("How your work is checked"). + (Save/Discard as header actions, `useSettingsUnsavedGuard` before any early return). +5. **Warm only what a second consumer needs.** Extract client-safe React Query options only + when a second consumer or a server boundary needs them; otherwise keep them with the hook. + An approved intent warmer reuses those exact options, keeps `check-tool-registry-boundary` + green, warms only authorized destinations, keeps the current section through the + transition and follows `sim-react-performance.md` recovery rules; it never renders + temporary default data that load later replaces. +6. **Verify:** the local gate in the root `CLAUDE.md` ("How your work is checked"). ## Mode B — Audit existing settings pages @@ -45,28 +56,26 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa Each grep lists candidates; review every match against the expected ones named below. 1. Find hand-rolled shells that should be `SettingsPanel`: - `git grep -n "flex h-full flex-col bg-\[var(--bg)\]" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'` - — expected matches: the workspace and organization `settings/layout.tsx` shells and the - shared header shell (`components/settings/settings-header.tsx`); an entitlement/loading gate - is also fine. `CredentialDetailLayout` (the `settings/secrets/[credentialId]` exception) is - an exempt hand-rolled shell outside these pathspecs. A detail sub-view is never a match: it + `git grep -n "flex h-full flex-col bg-\[var(--bg)\]" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' ':(exclude)apps/sim/platform/**'` + — expected match: the shared header shell (`components/settings/settings-header.tsx`); an + entitlement/loading gate is also fine. A detail sub-view is never a match: it passes `back={{ text, icon: ArrowLeft, onSelect }}` to `SettingsPanel`. Anything else is a violation: render it through `SettingsPanel`. 2. Find hand-rolled title blocks: - `git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'` + `git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' ':(exclude)apps/sim/platform/**'` — the only title is the `

` in `settings-header.tsx`; a non-heading value at that size - (e.g. the credit balance in `ee/organization-usage/components/usage-credits.tsx`) is fine. + (e.g. a metric value in `components/settings/sections/mothership/mothership.tsx`) is fine. 3. Find literal pixel text sizes (should be 0 — see "Text Scale" in `.claude/rules/sim-styling.md`): - `git grep -nE "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'`. + `git grep -nE "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' ':(exclude)apps/sim/platform/**'`. Display type above the scale (`text-[40px]` hero headings) is deliberate and out of scope. 4. Confirm each page imports `SettingsPanel` and that its registry entry has an accurate `description` of consistent length with its peers. - - Editable pages: confirm Save/Discard go through `saveDiscardActions()` and + - Editable pages: confirm Save/Discard are header actions (`id: 'discard'`, a primary Save) and dirty is wired via `useSettingsUnsavedGuard` (called before early-return gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal. - `git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' 'apps/sim/components/settings/' ':(exclude,glob)**/*.test.*'` + `git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' 'apps/sim/components/settings/' ':(exclude)apps/sim/platform/**' 'apps/sim/hooks/use-settings-browser-navigation.ts' ':(exclude,glob)**/*.test.*'` should only hit the centralized `use-settings-browser-navigation.ts`. 5. Fix each finding with the smallest structural change that satisfies the checklist; do not touch handlers, state, queries, or gate returns. A pixel-size fix swaps @@ -81,7 +90,7 @@ Read "The resource row" in `.claude/rules/sim-settings-pages.md` first — it is contract. Then, per page: 1. Find hand-rolled rows: - `git grep -n "truncate text-\[var(--text-body)\] text-sm" -- 'apps/sim/app/workspace/' 'apps/sim/ee/'` + `git grep -n "truncate text-\[var(--text-body)\] text-sm" -- 'apps/sim/components/settings/' 'apps/sim/ee/access-requests/components/'` Every match outside `settings-resource-row.tsx` is either a row to migrate or a genuinely different shape (multi-line body, tabular columns, a grid) that stays bespoke — decide which, and say so. diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index ddcb7a625bd..3ca82736d80 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -66,7 +66,7 @@ case with trusted execution context; use the `migrate-application-operation` ski Use this structure only for an absolute external provider API: -```typescript +```typescript illustrative import type { {ServiceName}{Action}Params, {ServiceName}{Action}Response, @@ -406,7 +406,7 @@ output: { Create `types.ts` with interfaces for all params and responses: -```typescript +```typescript illustrative import type { ToolResponse } from '@/tools/types' // Parameter interfaces @@ -430,7 +430,7 @@ Each response interface is imported by its tool's config: `ToolConfig { **Primary trigger** — MUST include `includeDropdown: true`: -```typescript +```typescript illustrative import { {Service}Icon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers/subblocks' import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' @@ -133,7 +133,7 @@ export const {service}EventATrigger: TriggerConfig = { **Secondary triggers** — NO `includeDropdown` (it's already in the primary): -```typescript +```typescript illustrative export const {service}EventBTrigger: TriggerConfig = { // Same as above but: id: '{service}_event_b', no includeDropdown } @@ -143,14 +143,14 @@ export const {service}EventBTrigger: TriggerConfig = { ### `apps/sim/triggers/{service}/index.ts` -```typescript +```typescript illustrative export { {service}EventATrigger } from './event_a' export { {service}EventBTrigger } from './event_b' ``` ### `apps/sim/triggers/registry.ts` -```typescript +```typescript illustrative import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' export const TRIGGER_REGISTRY: TriggerRegistry = { @@ -215,7 +215,7 @@ If none apply, you don't need a handler. The default handler provides bearer tok ### Example Handler -```typescript +```typescript illustrative import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' @@ -379,7 +379,7 @@ apps/sim/lib/webhooks/polling/ ### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) -```typescript +```typescript illustrative import { pollingIdempotency } from '@/lib/core/idempotency/service' import type { PollingProviderHandler, PollOutcome, PollWebhookContext } from '@/lib/webhooks/polling/types' import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' @@ -429,7 +429,7 @@ export const {service}PollingHandler: PollingProviderHandler = { ### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) -```typescript +```typescript illustrative import { {Service}Icon } from '@/components/icons' import type { TriggerConfig } from '@/triggers/types' @@ -464,7 +464,7 @@ export const {service}PollingTrigger: TriggerConfig = { Add to `helm/sim/values.yaml` under the existing polling cron jobs: -```yaml +```yaml illustrative {service}WebhookPoll: enabled: true name: {service}-webhook-poll diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md index 1ddfddcce6e..cc5a2d88065 100644 --- a/.agents/skills/cleanup/SKILL.md +++ b/.agents/skills/cleanup/SKILL.md @@ -1,6 +1,6 @@ --- name: cleanup -description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, comments, and test-audit — analyzing in parallel, then applying fixes sequentially +description: Run all code quality skills — effects, memo, callbacks, state, React Query, design review, url-state, comments, and test-audit — analyzing in parallel, then applying fixes sequentially argument-hint: "[scope] [fix=true|false]" --- @@ -25,14 +25,14 @@ Run these in parallel on the parsed `scope`: 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` +6. Design review: route `apps/sim/app/(product)/**` and `apps/sim/platform/**` to [implement-platform](../implement-platform/SKILL.md)'s review mode and legacy app UI to [emcn-legacy-design-review](../emcn-legacy-design-review/SKILL.md). For a mixed diff, the same pass reviews each subset with its own contract. Both reviews are read-only and return proposals for Step 2. 7. `/you-might-not-need-url-state fix=false` 8. `/you-might-not-need-a-comment fix=false` 9. `/test-audit audit ` — read-only; only when the scope adds or changes test files (`*.test.ts(x)`, `*.integration.ts`, `**/e2e/**`, `apps/sim/scripts/test-*-e2e.ts`). First resolve a free-form scope to the concrete list of added or changed test paths (`git diff --name-only` against the scope's base) and pass those paths. It applies the authoring gate to every new or changed test and proposes deleting the ones that fail it. ## 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 → tests), 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. +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 → design → comments → tests), 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 @@ -40,7 +40,7 @@ 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 → 9. tests +1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. design → 8. comments → 9. tests 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. diff --git a/.agents/skills/design-taste-frontend/SKILL.md b/.agents/skills/design-taste-frontend/SKILL.md index 303a2ad1af0..819e1f95d40 100644 --- a/.agents/skills/design-taste-frontend/SKILL.md +++ b/.agents/skills/design-taste-frontend/SKILL.md @@ -1,10 +1,10 @@ --- name: design-taste-frontend source: https://github.com/leonxlnx/taste-skill — skills/taste-skill/SKILL.md -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. +description: Anti-slop design for marketing pages under apps/sim/app/(landing)/ and their redesigns. Reads the brief, infers a design direction, and ships pages that do not look templated, with an audit-first redesign path and a strict pre-flight check. Product UI (app/(product), platform, packages/emcn*) follows apps/sim/platform/AGENTS.md instead. --- -> **In this repo:** Tailwind 4 (CSS-first config in `apps/sim/app/_styles/globals.css`); animation via `import { motion } from 'framer-motion'` (not `motion/react`); icons from `@sim/emcn/icons`; colors through the CSS-variable tokens in `.claude/rules/sim-styling.md` (no hardcoded `text-gray-*`/hex/`zinc` utilities, no paired `dark:` utilities). This note overrides any conflicting guidance or code sample anywhere in this file. Fonts are fixed (Season body, Inter); never introduce new families, and never use Martian Mono on landing (`apps/sim/app/(landing)/CLAUDE.md`). Font weight is only `font-normal`/`font-medium`/`font-semibold`. Elevation uses the `shadow-subtle|medium|overlay|card` tokens. Type size uses named tokens, never `text-[Npx]`. Every labeled field inside a `ChipModalBody` is a `ChipModalField`. Use `bunx`, never `npx`. Do not add GSAP, Lenis, Three, or shadcn. On landing pages, motion is CSS first; an animation library stays out of the initial bundle and loads below the fold through `next/dynamic` (`apps/sim/app/(landing)/CLAUDE.md`). Landing copy and SEO follow `.claude/rules/constitution.md` and `.claude/rules/landing-seo-geo.md`. +> **In this repo:** this skill covers the marketing site, `apps/sim/app/(landing)/**`. Product UI (`apps/sim/app/(product)`, `apps/sim/platform`, `packages/emcn*`) follows `apps/sim/platform/AGENTS.md`. Tailwind 4 (CSS-first config in `apps/sim/app/_styles/globals.css`); animation via `import { motion } from 'framer-motion'` (not `motion/react`); icons from `@sim/emcn-legacy/icons`; colors through the CSS-variable tokens in `.claude/rules/sim-styling.md` (no hardcoded `text-gray-*`/hex/`zinc` utilities, no paired `dark:` utilities). This note overrides any conflicting guidance or code sample anywhere in this file. Fonts are fixed (Season body, Inter); never introduce new families, and never use Martian Mono on landing (`apps/sim/app/(landing)/CLAUDE.md`). Font weight is only `font-normal`/`font-medium`/`font-semibold`. Elevation uses the `shadow-subtle|medium|overlay|card` tokens. Type size uses named tokens, never `text-[Npx]`. Every labeled field inside a `ChipModalBody` is a `ChipModalField`. Use `bunx`, never `npx`. Do not add GSAP, Lenis, Three, or shadcn. On landing pages, motion is CSS first; an animation library stays out of the initial bundle and loads below the fold through `next/dynamic` (`apps/sim/app/(landing)/CLAUDE.md`). Landing copy and SEO follow `.claude/rules/constitution.md` and `.claude/rules/landing-seo-geo.md`. # tasteskill: Anti-Slop Frontend Skill @@ -78,7 +78,7 @@ After the design read, set three dials. Every layout, motion, and density decisi | 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`. +Use these (or user-overridden values) as global variables. Cross-references throughout this document use these exact variable names. --- @@ -92,7 +92,7 @@ Once you have the design read (Section 0) and dials (Section 1), pick the right | 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 | +| Shopify app surfaces | Polaris 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 | @@ -118,7 +118,7 @@ For these directions, there is **no single official package**. Build with native | 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. | +| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official Liquid Glass stylesheet.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | --- @@ -131,7 +131,7 @@ Unless the design read picks a real design system (Section 2.A), these are the d * **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. + * For v4: configure PostCSS with `@tailwindcss/postcss` (or use the Vite plugin), never the `tailwindcss` plugin. * **Animation:** **Motion** (the library formerly known as Framer Motion). Outside this repo import from `motion/react`; in this repo import from `framer-motion` (see the note at the top). * **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. @@ -141,7 +141,7 @@ Unless the design read picks a real design system (Section 2.A), these are the d * **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 -* **Icons:** in this repo, `@sim/emcn/icons` only — one family per tree, `strokeWidth` standardized. Outside this repo, pick one maintained library and standardize on it. +* **Icons:** in this repo, `@sim/emcn-legacy/icons` only — one family per tree, `strokeWidth` standardized. Outside this repo, pick one maintained library and standardize on it. ### 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. @@ -759,7 +759,7 @@ Document the current state before proposing changes: * **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. +* **Dial reading of the existing site** - infer the current DESIGN_VARIANCE / MOTION_INTENSITY / VISUAL_DENSITY dials. 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 @@ -774,7 +774,7 @@ 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. +4. **Motion layer** - add micro-interactions that match MOTION_INTENSITY 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. @@ -883,7 +883,7 @@ The sections below are vendored reference content. They give the agent real inst ## Appendix A - Install Commands per Design System -```bash +```bash illustrative # Material Web (Material 3) npm install @material/web @@ -1021,7 +1021,7 @@ Relevant official docs: - SwiftUI → Material ### What is NOT official -There is no `liquid-glass.css` from Apple for normal websites. +Apple publishes no Liquid Glass stylesheet for normal websites. A web approximation can use: - `backdrop-filter` diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md deleted file mode 100644 index 8a211f53c6b..00000000000 --- a/.agents/skills/emcn-design-review/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: emcn-design-review -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 staging", "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 - -- Components, `cn`, and tokens from the `@sim/emcn` barrel, never component subpaths -- Icons from `@sim/emcn/icons` - -## Design Tokens - -Use CSS variable pattern (`text-[var(--text-body)]`), 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` are legacy aliases resolving to it — flag new uses) -**Brand/accent**: `--brand-secondary`, `--brand-accent` -**Z-Index**: `--z-dropdown` (100), `--z-toast` (150), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-takeover` (500), `--z-shell-gate` (600) -**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 and chips - -Header/action chrome is `Chip`/`ChipLink` (variants `primary`, `destructive`, `outline`, `border`, `border-shadow`; omit `variant` for the bare chip, and `filled` is reserved for chip fields and triggers). Selection and toggles use the `active` prop, never a variant. A single-resource Delete is a plain chip behind `ChipConfirmModal`; `destructive` is only for at-scale actions (`.claude/rules/sim-settings-pages.md` "Deleting a resource"). `Button` is only for icon-only toolbar controls (`ghost`/`quiet`, `size='icon'`). - -## Delete/Remove Confirmations - -Use `ChipConfirmModal` (title "Delete/Remove {ItemType}", `confirm={{ label, onClick }}`, which defaults to destructive). Keep its fail-safe `defaultAction='dismiss'` per `.claude/rules/emcn-components.md` "Modal keyboard defaults". Put irreversible-consequence copy in `text`. - -## Toast - -`toast.success()`, `toast.error()`, `toast()` from `@sim/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. - -## Mobile - -Check at 320px and 390px with touch, then desktop and fullscreen. Use `Chip`/`ChipLink`'s `mobileIconOnly` for familiar mobile toolbar actions, preserving accessible names; keep labels for ambiguous choices. Keep navigation, primary actions, and dismissal reachable without hover or dragging; aim for 44px touch targets with compact visible icons/button faces and 16px editable text. Match behavior to the actual container or viewport breakpoint. Contain horizontal scrolling to intentional tables/code, fit overlays to the dynamic viewport, and keep the composer/actions reachable with the keyboard open. Reuse EMCN tokens and brief motion with reduced-motion support; preserve desktop geometry. - -## Anti-patterns to flag - -- Raw ` diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index c057b86b35d..060f82cfc5e 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -190,7 +190,7 @@ projection end to end. Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: -```ts +```ts illustrative export const renameWidget = defineAuthorizedWorkspaceUseCase({ operation: widgetOperations.rename, resolveContext: ({ input }: { input: RenameWidgetInput }) => @@ -254,7 +254,7 @@ Copilot is a surface adapter, not a separate application layer. If an HTTP or ot Create one domain-level Copilot application adapter with `createCopilotApplicationAdapter` instead of constructing delegated principals in every tool: -```ts +```ts illustrative executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) ``` @@ -272,7 +272,7 @@ Never construct authoritative delegation from model-provided workspace IDs, user Tool handlers own argument aliases, resumable legacy names, abort checks, tool-call reporting, and tool-specific presentation. They must not query managers directly for protected operations, manually authorize, or implement protected business behavior. If a Copilot-only compound action expresses real domain behavior, define a surface-neutral domain operation and application use case for it. -A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. An immediate same-request resolver followed by the operation may reuse one trusted principal, though the application operation still performs its own canonical authorization. Fresh authentication and authorization are required across lifecycle boundaries such as resumed tool calls, executor callbacks, queued or background work, upload control legs and finalization, durable completion, and long-running provider operations. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. An immediate same-request resolver followed by the operation may reuse one trusted principal, though the application operation still performs its own canonical authorization. Fresh authentication and authorization are required across lifecycle boundaries such as resumed tool calls, executor callbacks, queued or background work, upload control legs and finalization, durable completion, and long-running provider operations. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameByReference`. Surface adapters must not compose protected mutations. An atomic compound action requires one top-level semantic domain operation and application use case that owns the transaction and authoritative result. An explicitly best-effort application command may coordinate multiple operations only when it defines hard input and expansion caps, cancellation checkpoints, partial-result semantics, audit behavior, and rate/quota policy. Keep composition exceptional and explicit; ordinary tools should use the shared execution adapter. diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index 81e86004947..8efdabdce95 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -27,7 +27,7 @@ Read these before analyzing: ### Query keys and hooks Enforce `.claude/rules/sim-queries.md` (key factory with `all` + plural prefixes, `signal` forwarding, named `staleTime` constants reused by prefetches, `keepPreviousData` only on variable keys, `requestJson` boundary). Additionally: -- Key factories live next to their hooks — except a factory, standalone fetcher/mapper, or `staleTime` constant that a server module (a `prefetch.ts`, route, block, trigger) imports, which must live in a non-`'use client'` module under `hooks/queries/utils/` per `.claude/rules/sim-queries.md` (a `'use client'` export called from the server crashes SSR) +- Key factories live next to their hooks — except a factory, standalone fetcher/mapper, or `staleTime` constant that a server module (a `prefetch-*.ts`, route, block, trigger) imports, which must live in a non-`'use client'` module under `hooks/queries/utils/` per `.claude/rules/sim-queries.md` (a `'use client'` export called from the server crashes SSR) - Use `enabled` to prevent queries from running without required params - Warm data for hover/focus intent with `prefetchQueryOnIntent(queryClient, sharedQueryOptions)` (`hooks/queries/utils/prefetch-query-on-intent.ts`, which cleans up a speculative failure per `sim-react-performance.md`); never temporarily enable a mounted hidden observer, which can remain active after focus restoration and refetch data for closed UI - When gating a query by view or modal state, move every consumer to the active query too: imperative refresh/pagination, loading and error feedback, and data-derived controls must never read a disabled query or placeholder data from a previous key diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 342774b7858..97931dd099d 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -39,7 +39,7 @@ When the user runs `/ship`: - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) - Keep it concise 4. **Run the cleanup and test gates** - - If the diff modifies UI code (any non-test `.tsx` file, or anything under `apps/sim/components/`, `apps/sim/hooks/`, or `apps/sim/stores/`), run `/cleanup`. It fans out the React/UI passes (effects, memo, callbacks, state, React Query, emcn, url-state), the comment pass, and the test-audit pass, and applies fixes so they land in this commit. + - If the diff modifies UI code (any non-test `.tsx` file, any `*.module.css`, or anything under `apps/sim/components/`, `apps/sim/hooks/`, `apps/sim/stores/` or `apps/sim/platform/lib/data/`), run `/cleanup`. It fans out the React/UI passes (effects, memo, callbacks, state, React Query, design review, url-state), the comment pass, and the test-audit pass, and applies fixes so they land in this commit. - Otherwise, if the diff adds or changes tests (`*.test.ts(x)`, `*.integration.ts`, `**/e2e/**`, `apps/sim/scripts/test-*-e2e.ts`), run `/test-audit audit ` on its own. Every new or changed test must pass the authoring gate; delete the ones that don't rather than shipping them. - Then run the test files the diff adds or changes, plus the existing tests beside changed source files, with `bun run --cwd test ` (`bun run --cwd apps/sim test ` for the app; `*.integration.ts` needs the setup in `.claude/rules/sim-testing.md`). A failing test aborts ship. - Then run root `bun run test` from the repo root. It chains `test:scripts` (the `scripts/*.test.ts` suite CI runs) before every workspace suite; workspace-scoped runs skip it, which is how a `scripts/check-*.test.ts` failure has reached CI. A failing test aborts ship. @@ -165,7 +165,7 @@ Describe the checks, tests, and E2E artifacts run Use this command structure: ```bash -gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +gh pr create --base staging --title "" --body "" ``` ## Important Notes diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index afc070e00b5..dc1c375daf5 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -122,7 +122,7 @@ For **every** tool file, check: - [ ] Has response interfaces for every tool (extending `ToolResponse`) - [ ] Optional params use `?` in the interface (e.g., `replyTo?: string`) - [ ] Field names in types match actual API field names -- [ ] Shared response types are properly reused (e.g., `XTweetResponse` shared across tweet tools) +- [ ] Shared response types are properly reused (e.g., one `Response` shared by every tool returning that shape) ### Barrel Export (index.ts) - [ ] Every tool is exported @@ -460,7 +460,7 @@ The integration's page must carry a `{/* MANUAL-CONTENT-START:intro */}` section ``. If it is missing, write one using the template in add-integration → Step 8, and check an existing intro against what the block actually ships (no removed or unshipped operations). -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. +If an icon changed, `apps/sim/components/icons.tsx` is the source of truth and `apps/docs/components/icons/icons.tsx` is its generated mirror — they must end up byte-identical for that component. ### Validation Output diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 578e936f212..f524c5a573b 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -18,7 +18,7 @@ A key with an admin checkbox but no server gate passes every structural audit. A Record the builder, the `enforcement`, and the position. -- **Default permissive?** The builders hardcode `false` / `null` / `[]`, so the risk is a *name* that inverts the meaning — an `allowX` boolean. The checkbox renders `checked={!editingConfig[feature.configKey]}` (ticked = allowed), so a positively-named boolean renders backwards. +- **Default permissive?** The builders hardcode `false` / `null` / `[]`, so the risk is a *name* that inverts the meaning — an `allow` boolean. A feature key that is `true` reads as denied (`directlyDenied` in `ee/access-requests/lib/targets.ts` returns `config[configKey]`), so the editor shows it ticked only when false, and a positively-named boolean renders backwards. - **Position stable?** Declaration order is the wire order, and no test catches a reorder. If `git log -p` shows the key was ever *moved* rather than appended, every open editor reads as unsaved (`group-detail.tsx` dirty-checks stringified configs). - **Phrasing accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot workspace VFS and the enterprise platform context. Confirm `empty` says "none allowed", not "unrestricted". - **Does the `hint` tell the truth?** Highest-value read in this step. A `'capability'` key refuses at the API, so a hint saying it hides a tab, module, or nav item "from the sidebar" is a **lie an admin acts on** — they believe they are tidying chrome while withholding a module. The same string is reused as the prose for an *active* restriction, where "hide" is simply false. Any surviving "Hide the …" hint on a `'capability'` key is a finding, not a nit; check `label` and `category` the same way (a "Sidebar" or "Settings Tabs" section makes the claim structurally). @@ -46,10 +46,10 @@ Two structural guards must still be present: Confirm the assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`, `AssertsParserReturnsTheConfig`) — a zod generic degrading to `unknown` is invisible at runtime and quietly loses every call site's narrowing. -## Step 3: Admin UI (`ee/access-control/components/group-detail.tsx`) +## Step 3: Admin UI (`ee/access-control/lib/editor.ts`, rendered by the product's access-control section) - **Boolean:** appears automatically via `PLATFORM_FEATURES`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted one renders after every ordered section. -- **Nested allowlist / denylist** (one that qualifies a platform-feature boolean): renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists — `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` — are not in `featureExtras` and must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there. +- **Nested allowlist / denylist** (one that qualifies a platform-feature boolean): renders **nothing** unless it is in `FEATURE_PERMISSION_GRIDS` (`ee/access-control/lib/editor.ts`) — keyed by the *parent boolean's feature id*, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists — `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` — are not in `FEATURE_PERMISSION_GRIDS` and must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there. - For an **allowlist** picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). A **denylist** picker must do neither: clearing every entry is how an admin denies nothing, and a full selection is a real state that denies everything. - Check the parent is the right one (`allowedKnowledgeConnectors` under `hide-knowledge-base`, not `disable-knowledge-base-creation`). @@ -143,7 +143,7 @@ Each is deliberate and documented in the code; `add-permission-group-item` carri - **An actorless deployment run passes through** — a delegated executor principal in `mode: 'deployment'` with no resolvable subject acts with the workspace's authority; denying would 403 every scheduled run, webhook and public-API call. What such a run *does* is still governed by `assertPermissionsAllowed`, which is why the four run-scoped keys carry `enforcement: 'executor'`. - **Copilot is NOT exempt** — a delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess`. Copilot acts as the person. A proposal to exempt it is a finding. - **Capability is checked after the role check** — `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface, so refusing on capability first would hand a non-member an oracle for what the organization withholds. The v1 middleware states the same ordering in its TSDoc. Not a bug. -- **`allowedEgressHosts` does not exist** — there is no network-egress allowlist. Requests for one are a feature, not missing wiring. +- **There is no network-egress host allowlist.** Requests for one are a feature, not missing wiring. - **Nothing currently ships as `ui-only`** — the union member has no user; an absent `ui-only` key is not a gap. ## Report Format diff --git a/.agents/skills/you-might-not-need-a-comment/SKILL.md b/.agents/skills/you-might-not-need-a-comment/SKILL.md index 33c24e5a658..e2df5332ba3 100644 --- a/.agents/skills/you-might-not-need-a-comment/SKILL.md +++ b/.agents/skills/you-might-not-need-a-comment/SKILL.md @@ -21,7 +21,7 @@ This codebase's convention: **TSDoc for documentation; an inline `//` only for a ## Anti-patterns to detect 1. **Restates the code**: `// increment counter` above `counter++`, `// return the result` above `return result`, `// loop over items`. Delete. -2. **Narrates the obvious from names**: the function is `fetchUserById`, the comment says `// fetches a user by id`. The identifier already said it. Delete. +2. **Narrates the obvious from names**: the function is named fetchUserById and the comment says `// fetches a user by id`. The identifier already said it. Delete. 3. **Section-divider / banner comments**: `// ==== Helpers ====`, `// --- state ---`, `// #region`. Against convention. Delete (the code's structure is the structure). 4. **Commented-out code**: dead code left as a comment. Delete — git is the history. 5. **Redundant type/param echo in prose comments**: `// takes a string and returns a number` when the signature already says so. Delete. diff --git a/.agents/skills/you-might-not-need-an-effect/SKILL.md b/.agents/skills/you-might-not-need-an-effect/SKILL.md index df8b16d17f2..1a1fd9f1b92 100644 --- a/.agents/skills/you-might-not-need-an-effect/SKILL.md +++ b/.agents/skills/you-might-not-need-an-effect/SKILL.md @@ -19,4 +19,4 @@ Steps: ## Query-backed forms -When query data supplies the initial values for an editable form, do not copy it into draft state in an Effect. Render loading chrome in an outer component, then mount a keyed form child once data exists and initialize its state lazily from props. Key by the resource identity so every related draft, dialog, and upload state resets together when the resource changes. Keep independent queries in the outer component to preserve parallel fetching. +When query data supplies the initial values for an editable form, do not copy it into draft state in an Effect. Let an outer component own the read and render its loading state (in the Platform, nothing: loading paints nothing, per `apps/sim/platform/AGENTS.md`), then mount a keyed form child once data exists and initialize its state lazily from props. Key by the resource identity so every related draft, dialog, and upload state resets together when the resource changes. Keep independent queries in the outer component to preserve parallel fetching. diff --git a/.agents/skills/you-might-not-need-url-state/SKILL.md b/.agents/skills/you-might-not-need-url-state/SKILL.md index 9d0fe789ecf..4646cebba3e 100644 --- a/.agents/skills/you-might-not-need-url-state/SKILL.md +++ b/.agents/skills/you-might-not-need-url-state/SKILL.md @@ -7,7 +7,7 @@ argument-hint: "[scope] [fix=true|false]" # You Might Not Need URL State Arguments: -- scope: what to analyze (default: your current changes). Examples: "diff to staging", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase" +- scope: what to analyze (default: your current changes). Examples: "diff to staging", "PR #123", "app/(product)/workspace/[workspaceId]/tables/", "whole codebase" - fix: whether to apply fixes (default: true). Set to false to only propose changes. User arguments: $ARGUMENTS @@ -35,7 +35,7 @@ Read these before analyzing: 5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`). 6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand. 7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL. -8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). Add one whose fallback is the route's `loading.tsx` default export, or a real in-page fallback for a continuity-focused peer switch that deliberately has no `loading.tsx` (never `fallback={null}` on a page entry; see `sim-url-state.md` "Suspense boundary"). +8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `` wrapper (Next.js requires it for `useSearchParams`). In the Platform (`app/(product)/**`, which has no `loading.tsx`), the shell's `` already covers the page, and a lazily loaded part gets its own ``. Every other route wraps the page entry in a `` whose fallback is the route's `loading.tsx` default export. `sim-url-state.md` "Suspense boundary" has both cases. 9. **`import { z }` for param validation in client code**: use nuqs parsers instead. 10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`. diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 3bd45448d59..a49803270ac 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -5,7 +5,7 @@ paths: - "apps/sim/lib/landing/**" - "apps/sim/content/**" - "apps/sim/emails/broadcasts/**" - - "apps/sim/app/layout.tsx" + - "apps/sim/app/_shell/site-root-layout.tsx" - "apps/sim/app/manifest.ts" - "apps/sim/app/llms*.txt/**" - "apps/sim/app/changelog.xml/**" diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 6ed0a0e1d20..44d55bfa7eb 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -1,59 +1,12 @@ --- -description: EMCN component library patterns +description: Modern shared EMCN components and surface packages paths: - "packages/emcn/**" + - "packages/emcn-auth/**" + - "packages/emcn-docs/**" + - "packages/emcn-platform/**" --- -# EMCN Components +# Modern EMCN -Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from the `@sim/emcn/icons` subpath, and CSS modules from their file path. Never deep-import other component subpaths. The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, date field→`ChipDatePicker`). `ChipSwitch` is a segmented choice between options, not a replacement for `Switch`: a boolean on/off toggle stays `Switch`. For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover). - -## Chip chrome — single source of truth - -Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources: - -- **Surface, typography + content tokens:** `chip/chrome.ts` — `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells), and the row-state pair `chipHoverSurfaceClass` / `chipActiveSurfaceClass` (hover vs. selected — mutually exclusive, so a selected row holds its surface through hover; every hand-rolled row imports these rather than restating the literals). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. -- **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity. - -Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border` border (`chip/chrome.ts` still spells it through the legacy alias `--border-1`; new code writes `--border`). - -`MENU_STYLES` in `dropdown-menu/styles.ts` owns the shared dropdown, picker-list, and popover chrome. Its default menu rows are 28px at `text-small`; chip pills remain 30px at `text-sm`. Menus reuse the chip gap, row radius, and search-field surface and typography, with a 12px outer radius, 4px padding, and `shadow-medium`. Compose these tokens instead of overriding chrome at call sites. Within the package, shared initialization-time tokens use private imports to avoid public-barrel cycles; consumers import `MENU_STYLES` from `@sim/emcn`. - -## Component catalogue - -- **`Chip` / `ChipLink`** — the pill button (` + + + ) +} + +function ChatPanelFixture() { + const [chat, setChat] = useState({ key: 'workspace-chat-a', scopeId: 'workspace-chat-a' }) + const [userId, setUserId] = useState('user-a') + const [settings, setSettings] = useState(false) + const [narrow, setNarrow] = useState(false) + useEffect(() => initBrowserAgentTransport(), []) + return ( + + + + + +
+ {!settings && ( + + )} +
+
+
+
+
+ ) +} + +const root = document.getElementById('root') +if (!root) throw new Error('Missing fixture root') +createRoot(root).render( + + + +) +` + +/** + * Drives the product chat panel (`app/(product)/_components/chat/panel/resource-panels.tsx`) over + * the real resize hook in Electron: per-chat widths, persistence, clamps, drag interruptions, and the + * desktop browser view it hosts, whose bounds the product browser tab reports to the shell. + */ test('chat panel sizes survive navigation, chat switches, collapse, and layout constraints', async () => { const reportPath = process.env.CHAT_PANEL_REPORT_PATH ?? test.info().outputPath('chat-panel.json') const checks: { @@ -67,30 +233,32 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/' ) response.end( - '
' + '
' ) } }) try { - await check('load the production resource panel resize hook in Electron', async () => { + await check('load the product chat panel in Electron', async () => { const config = await loadPostcssConfig({}, SIM_DIR) - const cssPath = join(SIM_DIR, 'app/_styles/globals.css') const css = await postcss(config.plugins).process( - `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, - { from: cssPath } + '@import "./bootstrap.css";\n@import "@sim/emcn-platform/styles/platform.css";', + { from: join(SIM_DIR, 'platform/styles/bootstrap.css') } ) const bundle = await build({ - entryPoints: [FIXTURE], + stdin: { contents: FIXTURE_SOURCE, resolveDir: SIM_DIR, loader: 'tsx' }, bundle: true, + jsx: 'automatic', write: false, outfile: test.info().outputPath('fixture.js'), - external: ['node:async_hooks'], + external: ['node:async_hooks', 'postgres'], banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, format: 'iife', platform: 'browser', tsconfig: join(SIM_DIR, 'tsconfig.json'), define: { 'process.env.NODE_ENV': '"development"' }, + // One bundle inlines the file viewer's lazy Monaco editor, whose stylesheet loads its icon font. + loader: { '.ttf': 'dataurl' }, }) javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` @@ -184,31 +352,28 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c await expectWidth(620) }) - await check( - 'workspace and organization chats restore independent widths without remounting', - async () => { - await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() - await expectWidth(720) - await dragTo(830) - await page.getByRole('button', { name: 'organization-chat-a', exact: true }).click() - await expectWidth(720) - await dragTo(560) - await page.getByRole('button', { name: 'Settings', exact: true }).click() - await page.getByRole('button', { name: 'Back', exact: true }).click() - await expectWidth(560) - await page.getByRole('button', { name: 'workspace-chat-a', exact: true }).click() - await expectWidth(620) - await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() - await expectWidth(830) - } - ) + await check('workspace and organization chats restore independent widths', async () => { + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(720) + await dragTo(830) + await page.getByRole('button', { name: 'organization-chat-a', exact: true }).click() + await expectWidth(720) + await dragTo(560) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expectWidth(560) + await page.getByRole('button', { name: 'workspace-chat-a', exact: true }).click() + await expectWidth(620) + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(830) + }) await check( 'collapse preserves the expanded preference, including keyboard changes', async () => { - await page.getByRole('button', { name: 'Collapse resource view' }).click() + await page.getByRole('button', { name: 'Close chat panel' }).click() await expectWidth(0) - await page.getByRole('button', { name: 'Expand resource view' }).click() + await page.getByRole('button', { name: 'Open chat panel' }).click() await expectWidth(830) await divider.focus() await page.keyboard.press('ArrowRight') diff --git a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts index f83e6c61917..bb63a732d6e 100644 --- a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts +++ b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts @@ -102,11 +102,11 @@ test.describe('desktop tools against a live Sim', () => { JSON.stringify( { url: page?.url(), - composer: await page - ?.getByRole('textbox') - .last() - .inputValue({ timeout: 2_000 }) - .catch(() => null), + composer: page + ? await composer(page) + .inputValue({ timeout: 2_000 }) + .catch(() => null) + : null, pageErrors, requests: proxy .seen(testStartedAt) @@ -159,9 +159,8 @@ test.describe('desktop tools against a live Sim', () => { `/api/mothership/chats/${user.chats['Warm other chat']}`, `/api/mothership/chat/stream?chatId=${user.chats['Warm chat']}`, `/api/workspaces/${user.workspaceId}/files/folders`, - '/api/copilot/chats', + `/api/mothership/chats?organizationId=${user.organizationId}`, '/api/users/me/settings', - '/api/auth/oauth/connections', '/api/desktop/inbox', ]) await compile(path) @@ -217,8 +216,8 @@ test.describe('desktop tools against a live Sim', () => { await expect(page.getByText('Warmed up.')).toBeVisible({ timeout: 2 * COMPILE_MS }) await send(page, '[warm-stop] wait for Stop', COMPILE_MS) await expect(page.getByText('Stopping soon.')).toBeVisible({ timeout: COMPILE_MS }) - await click(page, page.getByRole('button', { name: 'Stop generation' }), COMPILE_MS) - await expect(page.getByRole('button', { name: 'Stop generation' })).toBeHidden({ + await click(page, stopButton(page), COMPILE_MS) + await expect(stopButton(page)).toBeHidden({ timeout: COMPILE_MS, }) await proxy.settled(COMPILE_MS) @@ -230,8 +229,9 @@ test.describe('desktop tools against a live Sim', () => { } } + /** The product's chat page: chats belong to the organization, not to a workspace. */ const chatPath = (user: SeededUser, title: string) => - `/workspace/${user.workspaceId}/chat/${user.chats[title]}` + `/o/${user.organizationId}/chat/${user.chats[title]}` /** Launches the app signed in as `user`, showing the chat titled `title`. */ async function openApp(user: SeededUser, title: string, timeout = 120_000): Promise { @@ -264,7 +264,15 @@ test.describe('desktop tools against a live Sim', () => { return page } - const composer = (page: Page) => page.getByRole('textbox').last() + /** The chat composer, named by its placeholder: first message, follow-up, or while a turn runs. */ + const composer = (page: Page) => + page.getByRole('textbox', { + name: /^(Ask about your business|Ask a follow-up|Add a follow-up)/, + }) + const stopButton = (page: Page) => page.getByRole('button', { name: 'Stop response' }) + /** The sidebar's chat list, where the user switches chats. */ + const chatLink = (page: Page, title: string) => + page.getByRole('region', { name: 'Chats', exact: true }).getByRole('link', { name: title }) /** The dev app's error overlay, if it is up, with the errors the window reported. */ async function devOverlay(page: Page): Promise<{ text: string; consoleOnly: boolean } | null> { @@ -350,7 +358,7 @@ test.describe('desktop tools against a live Sim', () => { title: string, timeout = 30_000 ): Promise { - await click(page, page.getByRole('link', { name: title }).first(), timeout) + await click(page, chatLink(page, title).first(), timeout) await expect(page).toHaveURL(new RegExp(`${user.chats[title]}$`), { timeout }) } @@ -442,7 +450,7 @@ test.describe('desktop tools against a live Sim', () => { await claim.arrival(ARRIVAL_MS, 'The read’s claim') const turnStream = proxy.seen(since).find(isChatStream) if (!turnStream) throw new Error('The turn’s stream never reached Sim') - await click(page, page.getByRole('link', { name: 'Other chat' }).first()) + await click(page, chatLink(page, 'Other chat').first()) // The view let go of the turn's stream, the moment a read tied to that view would end. // Electron gives the held claim 8 s, so this waits only a few. await expect.poll(() => turnStream.clientClosedAt, { timeout: 6_000 }).toBeDefined() @@ -488,7 +496,7 @@ test.describe('desktop tools against a live Sim', () => { await laterFolder.arrival(ARRIVAL_MS, 'The next folder') const stoppedAt = Date.now() - await click(page, page.getByRole('button', { name: 'Stop generation' })) + await click(page, stopButton(page)) // Stop cancels the import's request in flight, which ends the import, and records the call as // cancelled; the stopped import reports nothing that could contest that record. await expect.poll(() => laterFolder.isAbandoned, { timeout: 15_000 }).toBe(true) @@ -556,10 +564,13 @@ test.describe('desktop tools against a live Sim', () => { // A crash reports nothing on its way out, unlike a closed window: only the lapse of the // lease the page was renewing tells Sim the import is gone. const crashedAt = Date.now() - await app?.evaluate(({ webContents }) => { - for (const contents of webContents.getAllWebContents()) - if (contents.getURL().includes('/workspace/')) contents.forcefullyCrashRenderer() - }) + await app?.evaluate( + ({ webContents }, chatPage) => { + for (const contents of webContents.getAllWebContents()) + if (contents.getURL().includes(chatPage)) contents.forcefullyCrashRenderer() + }, + chatPath(user, 'Crashing import chat') + ) await agent.waitForResume(() => Boolean(agent.resultFor(callId())), 150_000) const result = agent.resultFor(callId()) expect(result?.data).toMatchObject({ outcomeUnknown: true }) @@ -585,8 +596,11 @@ test.describe('desktop tools against a live Sim', () => { // The desktop reloads into the login page once signing out completes, which would end any // tool; holding the sign-out shows the tool ends at sign-out itself, not at that reload. const signOut = proxy.hold((method, path) => method === 'POST' && path === '/api/auth/sign-out') - await click(page, page.getByRole('button', { name: user.name })) - await click(page, page.getByRole('menuitem', { name: 'Sign out' })) + await click(page, page.getByRole('button', { name: user.name, exact: true })) + await click( + page, + page.getByRole('dialog', { name: 'Account menu' }).getByRole('button', { name: 'Sign out' }) + ) await signOut.arrival(ARRIVAL_MS, 'The sign-out') await expect.poll(() => firstUpload.isAbandoned, { timeout: 15_000 }).toBe(true) const abandonedAt = Date.now() @@ -696,7 +710,7 @@ test.describe('desktop tools against a live Sim', () => { const lateClaim = proxy.hold(isDesktopClaim, { deliverIfAbandoned: true }) await send(page, '[stopped-claim] read my secret') const claim = await lateClaim.arrival(ARRIVAL_MS, 'The read’s claim') - await click(page, page.getByRole('button', { name: 'Stop generation' })) + await click(page, stopButton(page)) // Stop settles the call nobody has claimed yet as never started. await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/) lateClaim.release() @@ -771,11 +785,9 @@ test.describe('desktop tools against a live Sim', () => { expect.soft(call?.persistSeq, 'persist order').not.toBeNull() // Only registration and the foreground claim: no inbox, doorbell stream, executor claim, - // lease or completion. The workspace's activity poll is Sim's page, not the app: this Sim - // has Redis, so its page asks. + // lease or completion. const desktopRoutes = new Set(proxy.seen(since, '/api/desktop/').map((entry) => entry.path)) desktopRoutes.delete(REGISTRATION_PATH) - desktopRoutes.delete('/api/desktop/activity') expect .soft(desktopRoutes, 'desktop routes the app called') .toEqual(new Set(['/api/desktop/tool/authorize'])) diff --git a/apps/desktop/e2e/executor-sim.ts b/apps/desktop/e2e/executor-sim.ts index fbb462b98aa..907b47a2144 100644 --- a/apps/desktop/e2e/executor-sim.ts +++ b/apps/desktop/e2e/executor-sim.ts @@ -22,6 +22,13 @@ import { getErrorMessage } from '@sim/utils/errors' const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) export const WORKSPACE = 'ws-e2e' +/** The organization whose chat pages the window shows; chats live under `/o//chat`. */ +const ORGANIZATION = 'org-e2e' + +/** A chat's page in the product, where the window sits while background calls run. */ +export function chatRoute(sim: FixtureSim, chatId: string): string { + return `${sim.origin}/o/${ORGANIZATION}/chat/${chatId}` +} const LEASE_RENEW_MS = 1_000 export const RECONCILE_MS = 2_000 diff --git a/apps/desktop/e2e/fixtures/browser-chrome.tsx b/apps/desktop/e2e/fixtures/browser-chrome.tsx index 0fa62c97561..00922c71927 100644 --- a/apps/desktop/e2e/fixtures/browser-chrome.tsx +++ b/apps/desktop/e2e/fixtures/browser-chrome.tsx @@ -10,8 +10,8 @@ import { DropdownMenuTrigger, TabStrip, Tooltip, -} from '@sim/emcn' -import { File } from '@sim/emcn/icons' +} from '@sim/emcn-legacy' +import { File } from '@sim/emcn-legacy/icons' import { getErrorMessage } from '@sim/utils/errors' import { createPortal } from 'react-dom' import { createRoot } from 'react-dom/client' diff --git a/apps/desktop/e2e/fixtures/live-sim.ts b/apps/desktop/e2e/fixtures/live-sim.ts index aca1cddabcc..fd3b342ab00 100644 --- a/apps/desktop/e2e/fixtures/live-sim.ts +++ b/apps/desktop/e2e/fixtures/live-sim.ts @@ -630,10 +630,11 @@ export class ScriptedAgent { } } -/** A seeded user signed in to Sim, with one workspace and named chats. */ +/** A seeded user signed in to Sim, owning an organization with one workspace and named chats. */ export interface SeededUser { userId: string name: string + organizationId: string workspaceId: string sessionId: string /** The `better-auth.session_token` cookie value for the seeded session. */ @@ -645,6 +646,7 @@ export interface SeededUser { export class SimDatabase { readonly sql: postgres.Sql private readonly userIds: string[] = [] + private readonly organizationIds: string[] = [] constructor(private readonly config: LiveSimConfig) { this.sql = postgres(config.databaseUrl, { max: 4, onnotice: () => {} }) @@ -657,15 +659,20 @@ export class SimDatabase { await tx`delete from workspace where owner_id in ${tx(this.userIds)}` await tx`delete from project where owner_id in ${tx(this.userIds)}` await tx`delete from "user" where id in ${tx(this.userIds)}` + await tx`delete from organization where id in ${tx(this.organizationIds)}` }) } finally { await this.sql.end({ timeout: 5 }) } } - /** A user with a workspace, a session as the desktop sign-in creates, and one chat per title. */ + /** + * A user owning an organization and one of its workspaces, a session as the desktop sign-in + * creates, and one organization agent chat per title, as the product's chat page opens them. + */ async seedUser(chatTitles: string[]): Promise { const userId = generateId() + const organizationId = generateId() const workspaceId = generateId() const sessionId = generateId() const token = generateShortId() @@ -676,10 +683,14 @@ export class SimDatabase { await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at) values (${userId}, ${name}, ${email}, ${email}, true, now(), now())` await tx`insert into user_stats (id, user_id) values (${generateId()}, ${userId})` - await tx`insert into project (id, name, owner_id) - values (${workspaceId}, 'E2E fixture project', ${userId})` - await tx`insert into workspace (id, project_id, name, owner_id, billed_account_user_id) - values (${workspaceId}, ${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})` + await tx`insert into organization (id, name, slug) + values (${organizationId}, 'Desktop tools E2E organization', ${organizationId})` + await tx`insert into member (id, user_id, organization_id, role) + values (${generateId()}, ${userId}, ${organizationId}, 'owner')` + await tx`insert into project (id, name, organization_id, owner_id) + values (${workspaceId}, 'E2E fixture project', ${organizationId}, ${userId})` + await tx`insert into workspace (id, project_id, name, owner_id, organization_id, billed_account_user_id) + values (${workspaceId}, ${workspaceId}, 'Desktop tools E2E', ${userId}, ${organizationId}, ${userId})` await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type) values (${generateId()}, ${userId}, 'workspace', ${workspaceId}, 'admin')` await tx`insert into session (id, token, user_id, user_agent, expires_at, created_at, updated_at) @@ -687,15 +698,18 @@ export class SimDatabase { for (const title of chatTitles) { const chatId = generateId() chats[title] = chatId - await tx`insert into copilot_chats (id, user_id, workspace_id, type, title) - values (${chatId}, ${userId}, ${workspaceId}, 'mothership', ${title})` + await tx`insert into copilot_chats (id, user_id, organization_id, type, title, config) + values (${chatId}, ${userId}, ${organizationId}, 'mothership', ${title}, + ${tx.json({ conversationMode: 'agent' })})` } }) this.userIds.push(userId) + this.organizationIds.push(organizationId) const signature = createHmac('sha256', this.config.authSecret).update(token).digest('base64') return { userId, name, + organizationId, workspaceId, sessionId, cookie: encodeURIComponent(`${token}.${signature}`), diff --git a/apps/desktop/e2e/fixtures/update-notification.tsx b/apps/desktop/e2e/fixtures/update-notification.tsx index cf707a0a325..094e69c5acf 100644 --- a/apps/desktop/e2e/fixtures/update-notification.tsx +++ b/apps/desktop/e2e/fixtures/update-notification.tsx @@ -1,5 +1,5 @@ import { type ComponentType, StrictMode, useState } from 'react' -import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn' +import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn-legacy' import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime' import { createRoot } from 'react-dom/client' diff --git a/apps/desktop/e2e/local-files.spec.ts b/apps/desktop/e2e/local-files.spec.ts index 68d8c1a1274..bd435e0102f 100644 --- a/apps/desktop/e2e/local-files.spec.ts +++ b/apps/desktop/e2e/local-files.spec.ts @@ -59,23 +59,26 @@ test('native file tools remember folder consent across chats and restarts until const bundle = await build({ stdin: { contents: `import { createRoot } from 'react-dom/client'; -import { ToastProvider } from '@sim/emcn'; import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'; -import { PathParamsContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'; -import { Desktop } from '@/app/workspace/[workspaceId]/settings/components/desktop/desktop'; -import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'; -import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'; -import { SettingsSectionProvider } from '@/components/settings/settings-panel'; +import { FeatureFlagsProvider } from '@/lib/core/config/feature-flags-context'; +import { PlatformSurface } from '@sim/emcn-platform/surface/platform-surface'; +import { NotificationSession, NotificationTray } from '@/app/(product)/_components/notification-tray/notification-tray'; +import DesktopSection from '@/app/(product)/o/[organizationId]/settings/_components/sections/desktop/desktop-section'; +import { SurfaceScopeProvider } from '@/platform/lib/surface-scope'; const router = { bfcacheId: 'fixture', back: () => history.back(), forward: () => history.forward(), refresh: () => location.reload(), push: url => location.assign(url), replace: url => location.replace(url), prefetch: () => {} }; +const organization = { featureFlags: { 'mothership-computer-use': false } }; createRoot(document.getElementById('settings')).render( - - - - - - - + + + + + + location.assign(url)} /> + + + + );`, resolveDir: SIM_DIR, @@ -93,10 +96,11 @@ createRoot(document.getElementById('settings')).render( define: { 'process.env.NODE_ENV': '"development"' }, }) const config = await loadPostcssConfig({}, SIM_DIR) - const cssPath = join(SIM_DIR, 'app/_styles/globals.css') - const css = await postcss(config.plugins).process(readFileSync(cssPath, 'utf8'), { - from: cssPath, - }) + const css = await postcss(config.plugins).process( + '@import "./bootstrap.css";\n@import "@sim/emcn-platform/styles/platform.css";', + { from: join(SIM_DIR, 'platform/styles/bootstrap.css') } + ) + const stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` server = createServer(async (request, response) => { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname if (path === '/settings.js' || path === '/settings.css') { @@ -104,7 +108,7 @@ createRoot(document.getElementById('settings')).render( response.end( path.endsWith('.js') ? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text - : css.css + : stylesheet ) return } @@ -186,9 +190,9 @@ createRoot(document.getElementById('settings')).render( app.focus({ steal: true }) host.focus() }) - await expect(window.getByRole('heading', { name: 'Local files', exact: true })).toHaveText( - 'Local files' - ) + await expect( + window.getByRole('heading', { name: 'Local files', exact: true, level: 1 }) + ).toHaveText('Local files') const invoke = (input: DesktopLocalFileRequest) => window.evaluate(async (request) => { const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop @@ -465,21 +469,27 @@ createRoot(document.getElementById('settings')).render( path: test.info().outputPath('desktop-settings-full-access.png'), animations: 'disabled', }) - await window.evaluate(() => document.documentElement.classList.add('dark')) + const setTheme = (theme: 'light' | 'dark') => + window.evaluate( + (value) => + document.querySelector('[data-platform-ui]')?.setAttribute('data-theme', value), + theme + ) + await setTheme('dark') await window.screenshot({ path: test.info().outputPath('desktop-settings-full-access-dark.png'), animations: 'disabled', }) - await window.evaluate(() => document.documentElement.classList.remove('dark')) + await setTheme('light') expect(await invoke({ operation: 'read', toolCallId: 'notAuthorized' })).toMatchObject({ ok: false, }) await app?.close() app = await launch() window = await app.firstWindow() - await expect(window.getByRole('heading', { name: 'Local files', exact: true })).toHaveText( - 'Local files' - ) + await expect( + window.getByRole('heading', { name: 'Local files', exact: true, level: 1 }) + ).toHaveText('Local files') await expect .poll(() => window.evaluate(async () => @@ -892,9 +902,9 @@ createRoot(document.getElementById('settings')).render( await app?.close() app = await launch() window = await app.firstWindow() - await expect(window.getByRole('heading', { name: 'Local files', exact: true })).toHaveText( - 'Local files' - ) + await expect( + window.getByRole('heading', { name: 'Local files', exact: true, level: 1 }) + ).toHaveText('Local files') expect(await invoke({ operation: 'read', toolCallId: 'text' })).toMatchObject({ ok: true }) }) await test.step('forgetting a folder revokes native reads and survives restart', async () => { @@ -903,9 +913,9 @@ createRoot(document.getElementById('settings')).render( await app?.close() app = await launch() window = await app.firstWindow() - await expect(window.getByRole('heading', { name: 'Local files', exact: true })).toHaveText( - 'Local files' - ) + await expect( + window.getByRole('heading', { name: 'Local files', exact: true, level: 1 }) + ).toHaveText('Local files') const revoked = await requestPermission({ operation: 'read', toolCallId: 'text' }) await revoked.prompt .getByRole('button', { name: 'Allow folder', exact: true }) @@ -920,9 +930,9 @@ createRoot(document.getElementById('settings')).render( writeFileSync(join(source, 'report.txt'), 'replacement contents') app = await launch() window = await app.firstWindow() - await expect(window.getByRole('heading', { name: 'Local files', exact: true })).toHaveText( - 'Local files' - ) + await expect( + window.getByRole('heading', { name: 'Local files', exact: true, level: 1 }) + ).toHaveText('Local files') const replaced = await requestPermission({ operation: 'read', toolCallId: 'text' }) await replaced.prompt .getByRole('button', { name: "Don't allow", exact: true }) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 3d2f6e5bdb7..36baee793b3 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -187,13 +187,18 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#status')).toBeHidden() await expect .poll(() => - window.evaluate(async () => { - const fonts = await document.fonts.load('14px "Sim Auth Geist"') + window.locator('#title').evaluate(async (element) => { + const { fontFamily, fontSize, fontWeight } = getComputedStyle(element) + const fonts = await document.fonts.load(`${fontWeight} ${fontSize} ${fontFamily}`) return fonts.length > 0 && fonts.every((font) => font.status === 'loaded') }) ) .toBe(true) await expect(window.locator('#detail')).toHaveAttribute('role', 'status') + await window.screenshot({ + path: test.info().outputPath('offline.png'), + animations: 'disabled', + }) }) test('offline title-bar geometry follows native fullscreen state across reloads', async () => { diff --git a/apps/desktop/e2e/terminal-cancel.spec.ts b/apps/desktop/e2e/terminal-cancel.spec.ts index 1d639b7dacc..ef7321c75be 100644 --- a/apps/desktop/e2e/terminal-cancel.spec.ts +++ b/apps/desktop/e2e/terminal-cancel.spec.ts @@ -7,7 +7,14 @@ import type { SimDesktopApi } from '@sim/desktop-bridge' import { sleep } from '@sim/utils/helpers' import { randomInt } from '@sim/utils/random' import { recordCheck } from './check-report' -import { FixtureSim, launch, processRunning, registeredDevice, settled } from './executor-sim' +import { + chatRoute, + FixtureSim, + launch, + processRunning, + registeredDevice, + settled, +} from './executor-sim' /** * Stopping the agent's terminal commands in the real app, with real shells and a real tmux @@ -327,7 +334,7 @@ test.describe('terminal cancel', () => { launched.app.process().stdout?.on('data', (chunk: Buffer) => appOutput.push(chunk.toString())) launched.app.process().stderr?.on('data', (chunk: Buffer) => appOutput.push(chunk.toString())) const deviceId = await registeredDevice(sim) - await launched.window.goto(`${sim.origin}/workspace/ws-e2e/chat/${CHAT_PLAIN}`) + await launched.window.goto(chatRoute(sim, CHAT_PLAIN)) return { window: launched.window, deviceId, userData } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 45232483d1c..4aba83b380a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,13 +45,14 @@ "micromatch": "4.0.8", "safe-regex2": "5.1.2", "tldts": "7.4.19", - "pdf-lib": "1.17.1", - "@sim/auth-ui": "workspace:*" + "pdf-lib": "1.17.1" }, "devDependencies": { "@electron/fuses": "1.8.0", "@playwright/test": "1.64.1", "@sim/emcn": "workspace:*", + "@sim/emcn-auth": "workspace:*", + "@sim/emcn-legacy": "workspace:*", "@sim/tsconfig": "workspace:*", "@types/micromatch": "4.0.10", "@types/node": "24.19.2", diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index d8c74efdf14..a8c22e18d8d 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -7,6 +7,10 @@ import loadPostcssConfig from 'postcss-load-config' import { identityForOrigin } from './channels' const watch = process.argv.includes('--watch') +const PACKAGED_FONTS: Readonly> = { + 'geist.woff2': 'geist-variable.woff2', + 'geist-mono.woff2': 'geist-mono-variable.woff2', +} // Optional build-time default server origin (pre-release shares pointed at a // non-prod environment): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai. @@ -72,15 +76,15 @@ const renderer: BuildOptions = { { name: 'desktop-tailwind', setup(builder) { - builder.onLoad({ filter: /(?:shell|utilities|styles\.module)\.css$/ }, async ({ path }) => { + builder.onLoad({ filter: /(?:shell|utilities|auth)\.css$/ }, async ({ path }) => { const config = await loadPostcssConfig({}, resolve('../sim')) const result = await postcss(config.plugins).process(readFileSync(path, 'utf8'), { from: path, }) return { contents: result.css.replace( - /url\(["']?\.\/assets\/(geist[^)"']+)["']?\)/g, - 'url(./$1)' + /url\(["']?[^)"']*\/(geist[^)"']+)["']?\)/g, + (_url: string, filename: string) => `url(./${PACKAGED_FONTS[filename] ?? filename})` ), loader: path.endsWith('.module.css') ? 'local-css' : 'css', resolveDir: dirname(path), @@ -99,8 +103,8 @@ async function run(): Promise { stdio: 'inherit', }) mkdirSync('dist/renderer', { recursive: true }) - for (const font of ['geist-variable.woff2', 'geist-mono-variable.woff2']) { - cpSync(resolve('../../packages/auth-ui/src/assets', font), join('dist/renderer', font)) + for (const [source, asset] of Object.entries(PACKAGED_FONTS)) { + cpSync(resolve('../../packages/emcn/src/fonts', source), join('dist/renderer', asset)) } if (watch) { const { context } = await import('esbuild') diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts index f7218cd2af6..7d651615650 100644 --- a/apps/desktop/src/main/app-routes.test.ts +++ b/apps/desktop/src/main/app-routes.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { settingsRoute } from '@/main/app-routes' +import { newChatRoute, settingsRoute } from '@/main/app-routes' describe('settingsRoute', () => { it('opens the settings of the organization the user was last in', () => { @@ -18,8 +18,20 @@ describe('settingsRoute', () => { ['the app entry', '/home'], ['no saved route', undefined], ['a route that only starts like an organization', '/oauth/o/org-1'], + ['a route that only starts like the Platform', '/organizations/x'], + ['the retired Platform mount, which names no organization', '/org/settings/usage'], ['an unsafe route', '//evil.example/o/org-1'], ])('resolves settings through the web app from %s', (_label, lastRoute) => { expect(settingsRoute(lastRoute)).toBe('/workspace?redirect=settings') }) }) + +describe('newChatRoute', () => { + it('opens Home in the organization the user was last in', () => { + expect(newChatRoute('/o/org-1/people')).toBe('/o/org-1/home') + }) + + it("opens the app entry from a workspace route, never the workspace's legacy Home", () => { + expect(newChatRoute('/workspace/ws-1/workflows')).toBe('/home') + }) +}) diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts index 22d0ad9f9e5..45908bddcc3 100644 --- a/apps/desktop/src/main/app-routes.ts +++ b/apps/desktop/src/main/app-routes.ts @@ -44,13 +44,13 @@ function organizationIdFromRoute(lastRoute: string | undefined): string | null { } /** - * Route for "New Chat": the home (chat) surface of the workspace the user was - * last in, falling back to the app entry when the last route carries no - * workspace. + * Route for "New Chat": the Home of the organization the user was last in. Anything else, a + * workspace route included, opens the app entry, which lands in the user's own organization: + * every workspace they open renders there. */ export function newChatRoute(lastRoute: string | undefined): string { - const workspaceId = workspaceIdFromRoute(lastRoute) - return workspaceId ? `/workspace/${workspaceId}/home` : APP_ENTRY_ROUTE + const organizationId = organizationIdFromRoute(lastRoute) + return organizationId ? `/o/${organizationId}/home` : APP_ENTRY_ROUTE } /** @@ -63,5 +63,8 @@ export function settingsRoute(lastRoute: string | undefined): string { return `/workspace/${workspaceId}/settings/desktop` } const organizationId = organizationIdFromRoute(lastRoute) - return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE + if (organizationId) { + return `/o/${organizationId}/settings/general` + } + return SETTINGS_ENTRY_ROUTE } diff --git a/apps/desktop/src/main/desktop-executor/approval-notifier.test.ts b/apps/desktop/src/main/desktop-executor/approval-notifier.test.ts index 23118c45a02..e7d1e0f3dcf 100644 --- a/apps/desktop/src/main/desktop-executor/approval-notifier.test.ts +++ b/apps/desktop/src/main/desktop-executor/approval-notifier.test.ts @@ -9,7 +9,8 @@ function approval(toolCallId: string, chatId = 'chat-b'): DesktopApprovalItem { toolName: 'terminal', chatId, chatTitle: 'Fix CI', - workspaceId: 'ws-1', + organizationId: 'org-1', + workspaceId: null, summary: 'rm -rf build && npm test', } } @@ -78,6 +79,15 @@ describe('approval notifications', () => { expect(notifications[0]?.options.body).not.toContain('rm -rf') expect(notifications[0]?.options.body).not.toContain('Fix CI') notifications[0]?.click() + expect(opened).toEqual(['/o/org-1/chat/chat-b']) + }) + + it('opens a workspace chat from before the organization cutover at its workspace route', () => { + const { notifier, notifications, opened } = harness() + + notifier.update([{ ...approval('call-1'), organizationId: null, workspaceId: 'ws-1' }]) + notifications[0]?.click() + expect(opened).toEqual(['/workspace/ws-1/chat/chat-b']) }) diff --git a/apps/desktop/src/main/desktop-executor/approval-notifier.ts b/apps/desktop/src/main/desktop-executor/approval-notifier.ts index ac823de1971..2bc07f60ec1 100644 --- a/apps/desktop/src/main/desktop-executor/approval-notifier.ts +++ b/apps/desktop/src/main/desktop-executor/approval-notifier.ts @@ -57,9 +57,13 @@ export function createApprovalNotifier(deps: ApprovalNotifierDeps) { silent: !preferences.notificationSounds, }) if (!notification) return - const route = item.workspaceId - ? `/workspace/${encodeURIComponent(item.workspaceId)}/chat/${encodeURIComponent(item.chatId)}` - : undefined + const chat = `chat/${encodeURIComponent(item.chatId)}` + // A workspace chat from before the organization cutover redirects to its organization's. + const route = item.organizationId + ? `/o/${encodeURIComponent(item.organizationId)}/${chat}` + : item.workspaceId + ? `/workspace/${encodeURIComponent(item.workspaceId)}/${chat}` + : undefined notification.on('click', () => deps.openRoute(route)) notification.show() shown.set(item.toolCallId, notification) diff --git a/apps/desktop/src/main/desktop-executor/executor.test.ts b/apps/desktop/src/main/desktop-executor/executor.test.ts index 4fe2cc755fe..f1aca6329f0 100644 --- a/apps/desktop/src/main/desktop-executor/executor.test.ts +++ b/apps/desktop/src/main/desktop-executor/executor.test.ts @@ -863,6 +863,7 @@ describe('registration', () => { toolName: 'terminal', chatId: 'chat-a', chatTitle: 'Fix CI', + organizationId: null, workspaceId: 'ws-1', summary: 'npm publish', }, diff --git a/apps/desktop/src/main/desktop-executor/protocol.ts b/apps/desktop/src/main/desktop-executor/protocol.ts index 23e127097ca..b9a63e85dff 100644 --- a/apps/desktop/src/main/desktop-executor/protocol.ts +++ b/apps/desktop/src/main/desktop-executor/protocol.ts @@ -45,6 +45,8 @@ export type DesktopInboxItem = toolName: string chatId: string chatTitle: string | null + /** Absent from a Sim that predates it; a notification without it opens no chat. */ + organizationId: string | null workspaceId: string | null summary: string | null } @@ -147,6 +149,7 @@ function parseInboxItem(value: unknown): DesktopInboxItem | null { toolName: value.toolName, chatId: value.chatId, chatTitle: value.chatTitle, + organizationId: typeof value.organizationId === 'string' ? value.organizationId : null, workspaceId: value.workspaceId, summary: value.summary, } diff --git a/apps/desktop/src/main/desktop-executor/service.test.ts b/apps/desktop/src/main/desktop-executor/service.test.ts index 9542c34ff5e..0f81cd6a3a9 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -226,10 +226,70 @@ describe('desktop executor registration', () => { sim.registrations[0]?.(true) - await vi.waitFor(() => expect(desktopExecutor.getDevice()).not.toBeNull()) + await vi.waitFor(async () => expect(await desktopExecutor.getDevice()).not.toBeNull()) await vi.waitFor(() => expect(sim.requests).toContain('GET /api/desktop/inbox')) }) + it("offers the device to a turn sent while the sign-in's registration is still due", async () => { + const { sim, desktopExecutor } = await service() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) + try { + desktopExecutor.start() + desktopExecutor.refreshRegistration() + + // Asked before the debounced registration has even gone out, as a first send after sign-in. + const offered = desktopExecutor.getDevice() + await vi.advanceTimersByTimeAsync(0) + expect(sim.registrations).toHaveLength(0) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + sim.registrations[0]?.(true) + + await expect(offered).resolves.toMatchObject({ protocolVersion: 1 }) + await desktopExecutor.signOut() + } finally { + vi.useRealTimers() + } + }) + + it('never holds a turn on a registration that is only backing off', async () => { + const { sim, desktopExecutor } = await service() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) + try { + desktopExecutor.start() + await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + sim.registrations[0]?.(503) + await vi.advanceTimersByTimeAsync(0) + + // The retry is seconds away: the turn goes out unbound now rather than waiting on it. + let answered = false + const offered = desktopExecutor.getDevice().finally(() => { + answered = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(answered).toBe(true) + await expect(offered).resolves.toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('sends a turn unbound when the registration it waits on never answers', async () => { + const { sim, desktopExecutor } = await service() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) + try { + desktopExecutor.start() + const offered = desktopExecutor.getDevice() + await vi.waitFor(() => expect(sim.registrations).toHaveLength(1)) + + await vi.advanceTimersByTimeAsync(5_000) + + await expect(offered).resolves.toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('never resumes for a registration that Sim answers after sign-out', async () => { const { sim, desktopExecutor } = await service() desktopExecutor.start() @@ -239,7 +299,7 @@ describe('desktop executor registration', () => { sim.registrations[0]?.(true) await sleep(50) - expect(desktopExecutor.getDevice()).toBeNull() + expect(await desktopExecutor.getDevice()).toBeNull() expect(sim.requests).not.toContain('GET /api/desktop/inbox') expect(sim.requests).not.toContain('GET /api/desktop/inbox/stream') }) @@ -252,7 +312,7 @@ describe('desktop executor registration', () => { sim.registrations[0]?.(true) await sleep(100) - expect(desktopExecutor.getDevice()).toBeNull() + expect(await desktopExecutor.getDevice()).toBeNull() expect(sim.requests.filter((request) => request.includes('/api/desktop/inbox'))).toEqual([]) }) @@ -264,7 +324,7 @@ describe('desktop executor registration', () => { sim.registrations[0]?.(false) await sleep(100) - expect(desktopExecutor.getDevice()).toBeNull() + expect(await desktopExecutor.getDevice()).toBeNull() expect(sim.requests.filter((request) => request.includes('/api/desktop/inbox'))).toEqual([]) }) diff --git a/apps/desktop/src/main/desktop-executor/service.ts b/apps/desktop/src/main/desktop-executor/service.ts index b51d5afcde2..ad3a2d23a8e 100644 --- a/apps/desktop/src/main/desktop-executor/service.ts +++ b/apps/desktop/src/main/desktop-executor/service.ts @@ -47,6 +47,11 @@ const logger = createLogger('DesktopExecutorService') /** Batches the burst of cookie and preference events a sign-in produces into one registration. */ const REGISTRATION_DEBOUNCE_MS = 1_000 const REGISTRATION_RETRY_MAX_MS = 5 * 60_000 +/** + * How long a turn sent before this device's due registration has answered waits for it. Past + * this the turn goes out unbound and runs in its chat view, as it would on a dormant device. + */ +const DEVICE_WAIT_MS = 5_000 /** How often a device Sim has not enabled checks whether that changed. */ const DORMANT_RECHECK_MS = 15 * 60_000 const ONLINE_POLL_MS = 2_000 @@ -73,7 +78,12 @@ export interface DesktopExecutorService { start(): void /** Re-registers after a sign-in, a session change, or a change to what this device can run. */ refreshRegistration(): void - getDevice(): DesktopExecutorDevice | null + /** + * The device a new turn binds to. While none is registered and a registration is due (the + * debounced one after sign-in, or one in flight), it waits up to {@link DEVICE_WAIT_MS} for + * that answer, so a turn sent in the first second after sign-in still runs in the background. + */ + getDevice(): Promise /** * The calls whose real result ({@link isDeliveredResult}) is in the journal and not yet * acknowledged, so recovery will hand it to the model. Read once, before recovery can change the @@ -134,6 +144,10 @@ export function createDesktopExecutorService( let doorbell: InboxDoorbell | null = null let reconcileTimer: ReturnType | null = null let registrationTimer: ReturnType | null = null + /** The scheduled registration answers a trigger now, rather than being a backoff or recheck. */ + let registrationDue = false + /** Settles for the turns waiting on a due registration once it has answered. */ + let dueRegistration: { settled: Promise; settle: () => void } | null = null let registering = false let registerAgain = false let registrationAttempt = 0 @@ -347,6 +361,8 @@ export function createDesktopExecutorService( nextTiming.enabled && nextTiming.protocolVersion === DESKTOP_EXECUTOR_PROTOCOL_VERSION ? { deviceId: id, protocolVersion: DESKTOP_EXECUTOR_PROTOCOL_VERSION } : null + // A waiting turn needs only the device, not the executor's recovery that follows. + if (device) releaseDeviceWaiters() logger.info('Desktop executor registered', { enabled: nextTiming.enabled }) // Off on this Sim, and nothing here to finish: stay dormant. No inbox, no doorbell; only // a slow recheck, so switching the executor on in Sim reaches this device without a relaunch. @@ -410,13 +426,33 @@ export function createDesktopExecutorService( } } + function releaseDeviceWaiters(): void { + dueRegistration?.settle() + dueRegistration = null + } + + /** Releases the waiting turns once no due registration is scheduled or running. */ + function settleDueRegistration(): void { + if (registering || (registrationTimer && registrationDue)) return + releaseDeviceWaiters() + } + function scheduleRegistration(delayMs = REGISTRATION_DEBOUNCE_MS): void { if (!started) return if (registrationTimer) clearTimeout(registrationTimer) + registrationDue = delayMs <= REGISTRATION_DEBOUNCE_MS + if (registrationDue && !dueRegistration) { + let settle = () => {} + const settled = new Promise((resolve) => { + settle = resolve + }) + dueRegistration = { settled, settle } + } registrationTimer = setTimeout(() => { registrationTimer = null void runRegistration() }, delayMs) + settleDueRegistration() } /** One registration at a time; a trigger that lands mid-flight registers once more after it. */ @@ -433,6 +469,7 @@ export function createDesktopExecutorService( } while (registerAgain) } finally { registering = false + settleDueRegistration() } } @@ -484,7 +521,17 @@ export function createDesktopExecutorService( refreshRegistration() { scheduleRegistration() }, - getDevice() { + async getDevice() { + if (!device && dueRegistration) { + let timer: ReturnType | undefined + await Promise.race([ + dueRegistration.settled, + new Promise((resolve) => { + timer = setTimeout(resolve, DEVICE_WAIT_MS) + }), + ]) + clearTimeout(timer) + } return device }, pendingResults() { @@ -505,6 +552,7 @@ export function createDesktopExecutorService( generation += 1 if (registrationTimer) clearTimeout(registrationTimer) registrationTimer = null + settleDueRegistration() await resetExecutor() await journal.clear() await retireInstallId() diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index b5580f76393..bc0a28fbda9 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -318,7 +318,7 @@ describe('registerIpcHandlers', () => { }) deps = { appOrigin: () => APP, - getExecutorDevice: () => null, + getExecutorDevice: async () => null, allowHttpLocalhost: () => false, accountDataAvailable: () => true, isLocalPageUrl, diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index b88167462ac..a7afbd8c116 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -399,7 +399,7 @@ export interface IpcDeps { setOrigin: (origin: string) => Promise } /** The device a new chat turn may bind to, or null while Sim has not enabled one. */ - getExecutorDevice: () => DesktopExecutorDevice | null + getExecutorDevice: () => Promise } /** diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index d17830b2df7..4990b0a856a 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -18,18 +18,29 @@ function makeDeps(overrides: Partial = {}): TrayDeps { } function _chat(id: number): RecentChat { - return { id: `c${id}`, title: `Chat ${id}`, workspaceId: 'ws1', status: 'none' } + return { id: `c${id}`, title: `Chat ${id}`, route: `/o/org1/chat/c${id}`, status: 'none' } +} + +/** Answers the surface read with `org1` and the chat list with `chats`. */ +function respond(chats: unknown[]) { + return (url: string) => ({ + ok: true, + status: 200, + json: async () => + url.includes('/api/organizations/surface') + ? { organization: { id: 'org1' } } + : { success: true, data: chats }, + }) } describe('parseRecentChats', () => { it('derives the sidebar status semantics: active > unread > none', () => { const payload = { - chats: [ + data: [ // Streaming right now → active, regardless of seen state. { id: 'c1', title: 'Streaming', - workspaceId: 'ws1', activeStreamId: 's1', updatedAt: '2026-07-19T10:00:00Z', lastSeenAt: '2026-07-19T11:00:00Z', @@ -38,7 +49,6 @@ describe('parseRecentChats', () => { { id: 'c2', title: 'Fresh reply', - workspaceId: 'ws1', activeStreamId: null, updatedAt: '2026-07-19T10:00:00Z', lastSeenAt: '2026-07-19T09:00:00Z', @@ -47,7 +57,6 @@ describe('parseRecentChats', () => { { id: 'c3', title: 'Never seen', - workspaceId: 'ws1', activeStreamId: null, updatedAt: '2026-07-19T10:00:00Z', lastSeenAt: null, @@ -56,16 +65,15 @@ describe('parseRecentChats', () => { { id: 'c4', title: 'Caught up', - workspaceId: 'ws1', activeStreamId: null, updatedAt: '2026-07-19T10:00:00Z', lastSeenAt: '2026-07-19T11:00:00Z', }, // Legacy row without the status fields → no dot. - { id: 'c5', title: 'Legacy', workspaceId: 'ws1' }, + { id: 'c5', title: 'Legacy' }, ], } - expect(parseRecentChats(payload).map((chat) => chat.status)).toEqual([ + expect(parseRecentChats(payload, 'org1').map((chat) => chat.status)).toEqual([ 'active', 'unread', 'unread', @@ -75,6 +83,22 @@ describe('parseRecentChats', () => { }) }) +describe('parseRecentChats routes', () => { + it('opens each organization chat at its page: Search for an assistant chat, Chat otherwise', () => { + const payload = { + success: true, + data: [ + { id: 'c1', title: 'Build', mode: 'agent' }, + { id: 'c2', title: 'Ask', mode: 'assistant' }, + ], + } + expect(parseRecentChats(payload, 'org1').map((chat) => chat.route)).toEqual([ + '/o/org1/chat/c1', + '/o/org1/search/chat/c2', + ]) + }) +}) + describe('installTray', () => { beforeEach(() => { Tray.instances.length = 0 @@ -87,13 +111,9 @@ describe('installTray', () => { // were treated as a failure the menu would keep listing the signed-out // user's chat titles to whoever picks the machine up next. let signedIn = true - const fetchMock = vi.fn(async () => + const fetchMock = vi.fn(async (url: string) => signedIn - ? { - ok: true, - status: 200, - json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), - } + ? respond([{ id: 'c1', title: 'Secret', mode: 'agent' }])(url) : { ok: false, status: 401, json: async () => ({}) } ) vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) @@ -122,13 +142,9 @@ describe('installTray', () => { const inFlight = new Promise((resolve) => { release = resolve }) - const fetchMock = vi.fn(async () => { + const fetchMock = vi.fn(async (url: string) => { await inFlight - return { - ok: true, - status: 200, - json: async () => ({ chats: [{ id: 'c1', title: 'Secret', workspaceId: 'ws1' }] }), - } + return respond([{ id: 'c1', title: 'Secret', mode: 'agent' }])(url) }) vi.mocked(session.fromPartition).mockReturnValue({ fetch: fetchMock } as never) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 7ae06b73c72..94eb12731bc 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -204,7 +204,10 @@ export function addTrayEnvironmentSubscript( scaleFactor: TRAY_ICON_SCALE_FACTOR, }) } -const CHATS_API_PATH = '/api/copilot/chats' +/** The viewer's organization surface; with no organization named, the one they land in. */ +const SURFACE_API_PATH = '/api/organizations/surface' +/** The organization's chats, most recent first, as the web app's sidebar lists them. */ +const CHATS_API_PATH = '/api/mothership/chats' /** * Chat status for the menu dot, mirroring the sidebar's semantics: @@ -216,7 +219,8 @@ export type RecentChatStatus = 'active' | 'unread' | 'none' export interface RecentChat { id: string title: string - workspaceId: string + /** The chat's page in the web app. */ + route: string status: RecentChatStatus } @@ -242,22 +246,33 @@ function deriveChatStatus(chat: { return updatedAt > lastSeenAt ? 'unread' : 'none' } +/** The organization id of a `GET /api/organizations/surface` response, or null for any other shape. */ +function parseOrganizationId(payload: unknown): string | null { + if (typeof payload !== 'object' || payload === null) return null + const organization = (payload as { organization?: unknown }).organization + if (typeof organization !== 'object' || organization === null) return null + const id = (organization as { id?: unknown }).id + return typeof id === 'string' && id ? id : null +} + /** - * Extracts tray-usable chats from the /api/copilot/chats response. Chats - * without a workspace id are dropped — the deep link needs one — and the - * response is already sorted by recency server-side. + * Extracts tray-usable chats from the organization's chat list + * (`GET /api/mothership/chats?organizationId=`), already sorted by recency server-side. Each opens + * at its page in the organization: Search for an assistant chat, Chat otherwise. */ export function parseRecentChats( payload: unknown, + organizationId: string, limit: number = RECENT_CHATS_TOTAL ): RecentChat[] { if (typeof payload !== 'object' || payload === null) { return [] } - const chats = (payload as { chats?: unknown }).chats + const chats = (payload as { data?: unknown }).data if (!Array.isArray(chats)) { return [] } + const organization = `/o/${encodeURIComponent(organizationId)}` const result: RecentChat[] = [] for (const chat of chats) { if (result.length >= limit) { @@ -266,18 +281,15 @@ export function parseRecentChats( if (typeof chat !== 'object' || chat === null) { continue } - const { id, title, workspaceId } = chat as { - id?: unknown - title?: unknown - workspaceId?: unknown - } - if (typeof id !== 'string' || !id || typeof workspaceId !== 'string' || !workspaceId) { + const { id, title, mode } = chat as { id?: unknown; title?: unknown; mode?: unknown } + if (typeof id !== 'string' || !id) { continue } + const page = mode === 'assistant' ? 'search/chat' : 'chat' result.push({ id, title: typeof title === 'string' && title.trim() ? title.trim() : 'Untitled chat', - workspaceId, + route: `${organization}/${page}/${encodeURIComponent(id)}`, status: deriveChatStatus(chat as Record), }) } @@ -323,10 +335,6 @@ function statusDotImage(status: RecentChatStatus): NativeImage | undefined { return status === 'active' ? dotImages.active : dotImages.unread } -export function chatRoute(chat: RecentChat): string { - return `/workspace/${chat.workspaceId}/chat/${chat.id}` -} - export interface TrayDeps { partition: () => string appOrigin: () => string @@ -343,7 +351,7 @@ function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOpti label, accessibilityLabel: statusLabel ? `${label}, ${statusLabel}` : label, ...(icon ? { icon } : {}), - click: () => deps.openMainWindow(chatRoute(chat)), + click: () => deps.openMainWindow(chat.route), } } @@ -419,20 +427,37 @@ export function installTray(deps: TrayDeps): TrayHandle | null { const fetchRecentChats = async (): Promise => { const ses = session.fromPartition(deps.partition()) - const response = await ses.fetch(`${deps.appOrigin()}${CHATS_API_PATH}`, { - credentials: 'include', - signal: AbortSignal.timeout(CHATS_FETCH_TIMEOUT_MS), - }) + const read = (path: string) => + ses.fetch(`${deps.appOrigin()}${path}`, { + credentials: 'include', + signal: AbortSignal.timeout(CHATS_FETCH_TIMEOUT_MS), + }) // Signed out is an ANSWER, not a failure: the correct list for no user is // empty. Throwing here would fall into the catch below and leave the // previous user's chat titles in the menu until someone signed back in. - if (response.status === 401 || response.status === 403) { + const signedOut = (response: { status: number }) => + response.status === 401 || response.status === 403 + const surface = await read(SURFACE_API_PATH) + if (signedOut(surface)) { + return [] + } + if (!surface.ok) { + throw new Error(`organization fetch failed: ${surface.status}`) + } + const organizationId = parseOrganizationId(await surface.json()) + if (!organizationId) { + throw new Error('organization fetch returned no organization') + } + const response = await read( + `${CHATS_API_PATH}?${new URLSearchParams({ organizationId, scope: 'active' })}` + ) + if (signedOut(response)) { return [] } if (!response.ok) { throw new Error(`chats fetch failed: ${response.status}`) } - return parseRecentChats(await response.json()) + return parseRecentChats(await response.json(), organizationId) } const replaceCachedChats = (chats: RecentChat[]) => { diff --git a/apps/desktop/src/renderer/credential-picker/index.tsx b/apps/desktop/src/renderer/credential-picker/index.tsx index b492059c07b..b628dc2cd90 100644 --- a/apps/desktop/src/renderer/credential-picker/index.tsx +++ b/apps/desktop/src/renderer/credential-picker/index.tsx @@ -6,7 +6,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, OverflowText, -} from '@sim/emcn' +} from '@sim/emcn-legacy' import { createRoot } from 'react-dom/client' import { initializeShellPage } from '@/renderer/shell' import type { diff --git a/apps/desktop/src/renderer/dialog/index.tsx b/apps/desktop/src/renderer/dialog/index.tsx index 1032d4a3914..c5bb9baa912 100644 --- a/apps/desktop/src/renderer/dialog/index.tsx +++ b/apps/desktop/src/renderer/dialog/index.tsx @@ -4,7 +4,7 @@ import { ChipModalFooter, ChipModalHeader, ChipModalSurface, -} from '@sim/emcn' +} from '@sim/emcn-legacy' import { createRoot } from 'react-dom/client' import { initializeShellPage, mountShellModal, shellWindow } from '@/renderer/shell' import type { ShellDialogConfiguration } from '@/shared/shell' diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx index 2b507f46e3b..6df760f6d3f 100644 --- a/apps/desktop/src/renderer/offline/index.tsx +++ b/apps/desktop/src/renderer/offline/index.tsx @@ -1,13 +1,11 @@ import { useState } from 'react' -import { - AuthRoot, - AuthShellView, - AuthTextLink, - AuthWordmark, - AuthButton as Chip, - AuthStatusContent as StatusPageContent, -} from '@sim/auth-ui' import { observeDesktopTitleBar, type SimDesktopApi } from '@sim/desktop-bridge' +import { Wordmark } from '@sim/emcn/brand/wordmark' +import { AuthButton as Chip } from '@sim/emcn-auth/button' +import { AuthRoot } from '@sim/emcn-auth/root' +import { AuthShellView } from '@sim/emcn-auth/shell' +import { AuthStatusContent as StatusPageContent } from '@sim/emcn-auth/status' +import { AuthTextLink } from '@sim/emcn-auth/text-link' import { createRoot } from 'react-dom/client' import '@/renderer/auth.css' @@ -71,7 +69,7 @@ function OfflinePage({ isSimCloud }: OfflinePageProps) { } + logo={} titlebar={