diff --git a/.agents/skills/add-column-type/SKILL.md b/.agents/skills/add-column-type/SKILL.md index d46f1d0ec7d..16e0f6d1a33 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). @@ -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-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md index 8ac7f58a95c..50ba6afad61 100644 --- a/.agents/skills/add-enrichment/SKILL.md +++ b/.agents/skills/add-enrichment/SKILL.md @@ -65,7 +65,7 @@ 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' +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' @@ -111,7 +111,7 @@ 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). 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-settings-page/SKILL.md b/.agents/skills/add-settings-page/SKILL.md index 94ff0f182e7..d9d233d03b0 100644 --- a/.agents/skills/add-settings-page/SKILL.md +++ b/.agents/skills/add-settings-page/SKILL.md @@ -1,23 +1,26 @@ --- 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 @@ -27,10 +30,10 @@ Key paths: 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`. +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. 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 @@ -45,20 +48,19 @@ 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 + `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. `CredentialDetailLayout` (the `settings/secrets/[credentialId]` exception) is an exempt hand-rolled shell outside these pathspecs. 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 @@ -66,7 +68,7 @@ Each grep lists candidates; review every match against the expected ones named b - Editable pages: confirm Save/Discard go through `saveDiscardActions()` 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 +83,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/' 'apps/sim/ee/'` 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/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md index 1ddfddcce6e..af9eb79f1f2 100644 --- a/.agents/skills/cleanup/SKILL.md +++ b/.agents/skills/cleanup/SKILL.md @@ -25,7 +25,7 @@ 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/platform/**` to [implement-platform](../implement-platform/SKILL.md) and existing app EMCN paths to [emcn-design-review](../emcn-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. diff --git a/.agents/skills/design-taste-frontend/SKILL.md b/.agents/skills/design-taste-frontend/SKILL.md index 303a2ad1af0..346f1b3597d 100644 --- a/.agents/skills/design-taste-frontend/SKILL.md +++ b/.agents/skills/design-taste-frontend/SKILL.md @@ -4,7 +4,7 @@ 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. --- -> **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:** 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 @@ -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. diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md index 8a211f53c6b..0b46195b00b 100644 --- a/.agents/skills/emcn-design-review/SKILL.md +++ b/.agents/skills/emcn-design-review/SKILL.md @@ -1,6 +1,6 @@ --- name: emcn-design-review -description: Review UI code for alignment with the emcn design system — components, tokens, patterns, and conventions +description: Review the existing Sim app against packages/emcn. Route isolated Platform UI in apps/sim/platform to implement-platform. argument-hint: "[scope] [fix=true|false]" --- @@ -14,11 +14,11 @@ 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. +The existing Sim app uses `packages/emcn-legacy`, its Radix primitives, CVA variants, and CSS tokens. For `apps/sim/platform/**`, use [implement-platform](../implement-platform/SKILL.md). For mixed scopes, review each file under its owning system and preserve the requested `fix` mode for both. ## 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` +1. Read the emcn public barrel at `packages/emcn-legacy/src/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `packages/emcn-legacy/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. @@ -27,8 +27,8 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit ## Imports -- Components, `cn`, and tokens from the `@sim/emcn` barrel, never component subpaths -- Icons from `@sim/emcn/icons` +- Components, `cn`, and tokens from the `@sim/emcn-legacy` barrel, never component subpaths +- Icons from `@sim/emcn-legacy/icons` ## Design Tokens @@ -52,7 +52,7 @@ Use `ChipConfirmModal` (title "Delete/Remove {ItemType}", `confirm={{ label, onC ## Toast -`toast.success()`, `toast.error()`, `toast()` from `@sim/emcn`. Never custom notification UI. +`toast.success()`, `toast.error()`, `toast()` from `@sim/emcn-legacy`. Never custom notification UI. ## Badges diff --git a/.agents/skills/implement-platform/SKILL.md b/.agents/skills/implement-platform/SKILL.md new file mode 100644 index 00000000000..337a230e0c3 --- /dev/null +++ b/.agents/skills/implement-platform/SKILL.md @@ -0,0 +1,17 @@ +--- +name: implement-platform +description: Build, change, consolidate, remove, or review Sim Platform UI, the product routes in apps/sim/app/(product) and their data seam in apps/sim/platform. Use for code-first product routes, EMCN components, Paper handoff and design drift. +--- + +# Implement Sim Platform + +Start in the Sim checkout containing the requested product work. Read the [Platform contract](../../../apps/sim/platform/AGENTS.md) and [component ownership map](../../../apps/sim/platform/docs/component-ownership.md). These versioned files carry the current rules into a fresh chat. Use the [Platform README](../../../apps/sim/platform/README.md) for the local routes, fixture states, generators, and verification commands. + +1. **Establish scope on the real route.** Identify the product entry, interaction, and affected consumers. When rebuilding an existing workflow, trace the old navigation target, its access gate, and the actions reachable by that viewer. Use that evidence to decide which controls belong on the new page before consulting the broader API or provider catalog. Foundations and the library live in the EMCN packages; working UI and transient interaction state live in the route, with examples supplied through the server fixture flag; real data and operations arrive through the data seam. Reuse the same renderer in the browser and Sim Desktop. Read the installed Next guides for the file structure or framework behavior you are changing. + For organization Settings, work from the Settings rows of the [parity ledger](../../../apps/sim/platform/docs/org-parity.md): each row's Legacy source column names the old section, so inventory its viewer/deployment gates and every action reachable from it. Map old header buttons to the row or section they act on. Verify the same destination and drilldown on the fixture and authenticated routes; keep real writes outside fixture sessions. +2. **Resolve design intent.** For Paper-backed work, load the Paper MCP's guide (`get_guide`, topic `paper-mcp-instructions`), then use Paper to locate the live Shared or Platform master and the product states that use it. Exclude Marcus. Preserve the key and theme roles. Treat artboard sizes as reference measurements. For code-first changes, follow the latest explicit product direction and inspect the rendered route. Identify any source you could not verify. Update the Paper canvas when the request includes Paper changes. +3. **Choose the owner.** EMCN is frozen: compose it as it stands and pass it no new attribute, variant or option; only the design owner changes it (`check:emcn-surface`). Shared controls and foundations belong to `packages/emcn`; Platform-specific compositions belong to `packages/emcn-platform`. Auth and Docs compose the same Shared primitives through their own packages. Use public family subpaths, colocated CSS Modules and the single scoped style entry for each surface. Keep `@sim/emcn-legacy` outside the modern package graph. Start with the behavior. An action, navigation row, value picker, or disclosure has an existing keyboard and pointer contract. Different interactions may share visual roles while retaining different semantics. Use the existing family and named props for a change in size, intent, icon, or content. Toolbar choices use ChoiceMenu, as Home's period does; Select/SelectField belong to labeled forms. Immediate preferences, row roles and toolbar filters use compact text triggers without borders or chevrons. Compare the rendered trigger, not just its variant name. Put unique anatomy and its CSS together in `_components//.tsx` and `.module.css`. Match an existing semantic rhythm, such as Home's ActivityRow, for a custom composition. Keep page files free of styling. Only EMCN owns the appearance of its controls. Use Base UI there for complex keyboard, floating, and dismissal behavior. +4. **Trace the full change.** On a second independent feature use, promote the original implementation and migrate both callers. For consolidation, compare semantics, map every consumer to its replacement, then remove the obsolete implementation, export, variant styles, and unused registrations. For deletion, also check menus, preferences, fixture data and tests that existed only for the removed feature. Preserve unrelated consumers and historical Paper evidence. Before adding a shared visual variant, name at least two independent real consumers. Record its reachable Paper key in the catalog, or, with no counterpart, its reason in `CODE_FIRST_VARIANTS`; never an empty evidence list. A single feature composes the existing primitives in its local owner; a label, icon, data value or responsive arrangement does not justify another variant. Keep the public API small enough to understand at the call site. Edit the source or compiler for generated foundations and recipes. Before moving files across packages or renaming widely, merge the base branch and tell its owner, so parallel work lands before the move rather than against it. +5. **Verify where people use it.** Run the design audit and applicable checks from the README. Render the changed product route with empty, full, stress, and fixture-off data as relevant. Check both themes, widths between artboards, long content, text scaling, keyboard, and pointer states. Compare shared controls in their actual consumers. Check the actual scroll edge and the open popup when space is tight. For shell, panel, or desktop work, exercise the real Electron renderer. For substantial implementations and consolidations, use an independent agent during design or implementation and for a final review of the changed owners and routes. Preview fixtures with `SIM_PLATFORM_FIXTURES=1 bun run dev` in `apps/sim` (`/o/sim/home`) and live data with `bun run dev`. A separate worktree is a separate copy of the code; check which checkout serves a running preview before relying on it. Provide the working route for visual review. +6. **Keep non-UI code in one place.** Data, adapters and logic go in `apps/sim/platform/lib/data`, pure helpers in `apps/sim/platform/shared`; routes never hold a second copy. Build organization links with `surfaceHref` and the view's `useSurfaceScope()` (see "Product root and shell" in `apps/sim/platform/AGENTS.md`). +7. **Keep decisions durable.** Follow the contract’s maintenance guidance when an approved correction changes a shared rule. Record feature-specific decisions with their feature. For sustained work, keep a task checkpoint with the code revision, verified states, evidence, and outstanding work. Include the branch or PR, checkpoint path, and localhost route in a handoff. On resumption, compare the checkpoint with the current branch, working tree, and relevant live sources before acting. diff --git a/.agents/skills/implement-platform/agents/openai.yaml b/.agents/skills/implement-platform/agents/openai.yaml new file mode 100644 index 00000000000..7f4a26951bd --- /dev/null +++ b/.agents/skills/implement-platform/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Implement Sim Platform" + short_description: "Build and review the code-first Sim Platform" + default_prompt: "Use $implement-platform to implement the next Platform product change in Sim." diff --git a/.agents/skills/make-interfaces-feel-better/SKILL.md b/.agents/skills/make-interfaces-feel-better/SKILL.md index dfb709f427a..167ea44aa15 100644 --- a/.agents/skills/make-interfaces-feel-better/SKILL.md +++ b/.agents/skills/make-interfaces-feel-better/SKILL.md @@ -65,7 +65,7 @@ Add a subtle 1px low-opacity outline to images; see surfaces.md. In this repo, u ### 12. Scale on Press -A subtle scale-down (about 0.96-0.97) on press gives tactile feedback. In this repo a press affordance belongs in the emcn `Button`/`Chip` chrome (`packages/emcn`), not in consumer classes — neither component implements one today, so propose it there rather than adding per-call-site transforms. +A subtle scale-down (about 0.96-0.97) on press gives tactile feedback. In this repo a press affordance belongs in the emcn `Button`/`Chip` chrome (`packages/emcn-legacy`), not in consumer classes — neither component implements one today, so propose it there rather than adding per-call-site transforms. ### 13. Skip Animation on Page Load @@ -119,7 +119,7 @@ Present changes as markdown tables with **Before** and **After** columns, one ta #### Scale on press | Before | After | | --- | --- | -| `Chip` has no press affordance | Proposed in `packages/emcn/src/components/chip/chip.tsx` (`chipVariants`): `active:scale-[0.96] transition-transform` | +| `Chip` has no press affordance | Proposed in `packages/emcn-legacy/src/components/chip/chip.tsx` (`chipVariants`): `active:scale-[0.96] transition-transform` | | `scale(0.9)` on press | Raised to `scale(0.96)` — anything below `0.95` feels exaggerated | Rows should cite the specific file and the specific property that changed when it isn't obvious from the snippet. If a principle was reviewed but nothing needed to change, omit that table entirely — empty tables add noise. diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index afc070e00b5..5c24cb4e49f 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -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..342c4d719be 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -46,7 +46,7 @@ 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. 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..f40de097d22 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 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..dc493ed7b9a 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -1,59 +1,16 @@ --- -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). +Read `packages/emcn/AGENTS.md` and `packages/emcn/README.md`. The modern system derives from the new Platform UI. Shared primitives and foundations live in the EMCN package; Auth, Docs and Platform own only their specific compositions. `@sim/emcn-legacy` serves unreplaced routes and cannot enter the new package graph. -## Chip chrome — single source of truth +Import the public family (`@sim/emcn/button`, `@sim/emcn-auth/shell`) rather than an aggregate or a private source file. Keep each component's CSS and private helpers in its family folder. Use Base UI for complex interaction behavior, shared semantic roles for paint and geometry, and props for supported states. Keep data, permissions, routing and fixtures in the applications. -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..50d96fbcae9 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,7 +159,7 @@ 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', @@ -217,8 +217,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 +230,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 +265,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 +359,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 +451,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 +497,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 +565,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 +597,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 +711,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 +786,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 3fa0036c03f..8a2b13cb7e7 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.18", - "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..2ff2c845abc 100644 --- a/apps/desktop/src/main/desktop-executor/service.test.ts +++ b/apps/desktop/src/main/desktop-executor/service.test.ts @@ -226,10 +226,62 @@ 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() + 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.waitFor(() => expect(sim.registrations).toHaveLength(1), { timeout: 3_000 }) + sim.registrations[0]?.(true) + + await expect(offered).resolves.toMatchObject({ protocolVersion: 1 }) + await desktopExecutor.signOut() + }) + + 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 +291,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 +304,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 +316,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={