From 1bee7f0a9fa5051372948d5683d2e025b947db42 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 09:06:17 -0300 Subject: [PATCH 01/19] feat(cms-mode): host the CMS block editor in the side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast Preview projects rendered an inert chat panel — a "coming soon" notice — while the block editor was squeezed into a 31% pane nested inside the Preview tab. The side panel now hosts the editor and the preview canvas takes the whole main panel. Renames Fast Preview to CMS mode throughout, and consolidates the gate: `resolveCmsMode` in packages/shared is now the single reader, consumed by the web app and by both API gates (`decofile.ts`, `sandbox-proxy.ts`) that previously read `metadata.fastPreview` directly. `preview.tsx` also inlined its own copy; it now calls the helper. The persisted key is deliberately still written as `fastPreview` — `resolveCmsMode` reads `cmsMode` first and falls back to it. Flipping the write before every reader ships would 404 the CMS for any newly-toggled project, since the decofile API gates on it. - SidePanelKind widens to "chat" | "cms", including the six sites that hardcoded the literal. Three of those failed silently rather than at compile time (router zod schema, thread-layout memory, chat navigation) and would have dropped the CMS panel on navigation. - The nested blocks pane and its Edit content toggle are retired in CMS mode only. Emptying the pane was not enough: `defaultSize` applies at mount, so it left a 210px dead column. - The CMS tour anchor moves from the preview toolbar to the new toggle. - Sandbox projects are unchanged. Verified against a live CMS-mode project in the native app: toggle, panel mount, section list, `?sidepanel=cms` surviving a full reload, and both the nested pane and old toggle gone. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/api/routes/decofile.ts | 17 +- apps/api/src/api/routes/sandbox-proxy.ts | 7 +- apps/web/docs/cms-mode-plan.md | 283 ++++++++++++++++++ apps/web/docs/cms-mode-spec.md | 209 +++++++++++++ .../chat/hooks/use-chat-navigation.ts | 8 +- apps/web/src/components/chat/input.tsx | 10 +- .../sandbox/blocks/blocks-panel.tsx | 4 +- .../sandbox/hooks/sandbox-events-context.tsx | 10 +- .../hooks/sandbox-lifecycle-context.test.ts | 4 +- .../hooks/sandbox-lifecycle-context.tsx | 12 +- .../sandbox/preview/preview-display.test.ts | 24 +- .../sandbox/preview/preview-display.ts | 14 +- .../components/sandbox/preview/preview.tsx | 88 +++--- ...t-preview-field.tsx => cms-mode-field.tsx} | 39 +-- .../sections-editor/decofile-api.ts | 2 +- .../section-preview-url.test.ts | 36 ++- .../sections-editor/section-preview-url.ts | 6 +- .../sections-editor/use-decofile.ts | 8 +- .../sections-editor/use-delete-block.ts | 6 +- .../sections-editor/use-live-meta.ts | 6 +- .../sections-editor/use-save-block.ts | 6 +- .../use-section-preview-base.ts | 8 +- .../thread/github/cms-header-actions.tsx | 4 +- .../thread/github/header-actions.tsx | 16 +- apps/web/src/hooks/use-layout-state.test.ts | 66 ++++ apps/web/src/hooks/use-layout-state.ts | 60 +++- apps/web/src/i18n/en/agent-shell-layout.ts | 1 + apps/web/src/i18n/en/chat.ts | 4 +- apps/web/src/i18n/en/sandbox.ts | 10 +- apps/web/src/i18n/pt-br/agent-shell-layout.ts | 1 + apps/web/src/i18n/pt-br/chat.ts | 4 +- apps/web/src/i18n/pt-br/sandbox.ts | 10 +- .../src/layouts/agent-shell-layout/index.tsx | 4 + .../agent-shell-layout/toggle-buttons.tsx | 37 ++- .../workspace-panel-group.tsx | 69 ++--- .../main-panel-tabs/blocks-tab-state.test.ts | 12 +- .../main-panel-tabs/blocks-tab-state.ts | 4 +- .../src/layouts/resolve-task-switch-search.ts | 3 +- apps/web/src/layouts/shell-layout.tsx | 5 +- apps/web/src/lib/thread-layout-memory.test.ts | 9 + apps/web/src/lib/thread-layout-memory.ts | 16 +- apps/web/src/router.tsx | 4 +- apps/web/src/sdk/cms-mode.ts | 14 + apps/web/src/sdk/fast-preview.ts | 28 -- .../web/src/views/virtual-mcp/header-info.tsx | 6 +- apps/web/src/views/virtual-mcp/index.tsx | 4 +- packages/e2e/tests/decofile-api.spec.ts | 28 +- packages/shared/src/cms-mode.ts | 48 +++ packages/shared/src/sdk/types/virtual-mcp.ts | 10 +- 49 files changed, 999 insertions(+), 285 deletions(-) create mode 100644 apps/web/docs/cms-mode-plan.md create mode 100644 apps/web/docs/cms-mode-spec.md rename apps/web/src/components/sandbox/runtime-card/{fast-preview-field.tsx => cms-mode-field.tsx} (55%) create mode 100644 apps/web/src/sdk/cms-mode.ts delete mode 100644 apps/web/src/sdk/fast-preview.ts create mode 100644 packages/shared/src/cms-mode.ts diff --git a/apps/api/src/api/routes/decofile.ts b/apps/api/src/api/routes/decofile.ts index eefa1aaa2b..d6d8bf2a82 100644 --- a/apps/api/src/api/routes/decofile.ts +++ b/apps/api/src/api/routes/decofile.ts @@ -9,16 +9,14 @@ * POST /api/:org/decofile/:virtualMcpId/:branch/publish merge into default (session) * GET /api/:org/decofile/:virtualMcpId/:branch/status drift vs default (session) * - * The surface is inert unless the virtual MCP has Fast Preview active - * (metadata.fastPreview + valid previewServerUrl, legacy key productionUrl) — - * see resolveFastPreview / resolvePreviewServerUrl. + * The surface is inert unless `resolveCmsMode` says CMS mode is active. * * Anonymous access: `resolveOrgFromPath` lets unauthenticated requests through * (membership is only enforced for signed-in principals), so the GET handler * self-enforces the signed draft token, mirroring automation-webhooks.ts. */ -import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +import { resolveCmsMode } from "@decocms/shared/cms-mode"; import type { GithubRepo } from "@decocms/shared/sdk/types"; import { assertSafeDecoBlockKey } from "@decocms/shared/decofile"; import { Hono, type Context } from "hono"; @@ -118,14 +116,9 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { } const metadata = (virtualMcp.metadata as Record) ?? null; - // Fast Preview gate — same two-part condition the web derives via - // resolveFastPreview: the flag alone is inert without a valid production URL. - const previewServerUrl = resolvePreviewServerUrl(metadata); - if (!previewServerUrl || metadata?.fastPreview !== true) { - return c.json( - { error: "Fast Preview is not enabled for this project" }, - 404, - ); + // CMS-mode gate — the shared rule, so web and API cannot drift. + if (!resolveCmsMode(metadata).active) { + return c.json({ error: "CMS mode is not enabled for this project" }, 404); } const connectionIds = diff --git a/apps/api/src/api/routes/sandbox-proxy.ts b/apps/api/src/api/routes/sandbox-proxy.ts index 4d13a2e25c..af6ecd4215 100644 --- a/apps/api/src/api/routes/sandbox-proxy.ts +++ b/apps/api/src/api/routes/sandbox-proxy.ts @@ -38,7 +38,7 @@ import { suggestCommitMessageWithLlm, } from "../../lib/suggest-commit-message"; import { judgeRequiresReviewWithLlm } from "../../lib/judge-requires-review"; -import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +import { resolveCmsMode } from "@decocms/shared/cms-mode"; import { gitDataClientForRepo } from "../../decofile/client-for-repo"; import { GitHubApiError } from "../../decofile/github-git-data"; import { @@ -209,10 +209,7 @@ const resolveVmClaim = createMiddleware(async (c, next) => { // Sandbox-less Fast Preview: there is no runner by design. Claim the route // with runner:null + the flag so the `/git/*` handlers serve their // GitHub-backed equivalents; daemon-backed routes 503 via requireRunner. - if ( - virtualMcpMetadata?.fastPreview === true && - resolvePreviewServerUrl(virtualMcpMetadata) - ) { + if (resolveCmsMode(virtualMcpMetadata).active) { c.set("vmClaim", { claimName, callerUserId: userId, diff --git a/apps/web/docs/cms-mode-plan.md b/apps/web/docs/cms-mode-plan.md new file mode 100644 index 0000000000..d5a180793f --- /dev/null +++ b/apps/web/docs/cms-mode-plan.md @@ -0,0 +1,283 @@ +# CMS mode / Vibecoding mode — implementation plan + +Implements [`cms-mode-spec.md`](./cms-mode-spec.md). + +> **Revised after a 7-perspective critique.** The first draft was one PR built on five false premises. It is +> now five PRs built on verified ones. See [Critique decisions](#critique-decisions). + +> **Rebased on `main` (`76d13142b`), after PR #6054.** All pointers below were re-verified against the +> rebased tree — every finding survived; only `preview.tsx` line numbers moved, and they are resynced. +> `previewOrigin(previewUrl)` now has **five** call sites (`:876, 915, 925, 932, 954`), not two. + +**Blast radius, counted not estimated:** `fastPreview`/`fast-preview` appears **211 times across 34 files in +4 workspaces** (`apps/web`, `apps/api`, `packages/shared`, `packages/e2e`), plus ~20 more files for the panel +work. One PR is not reviewable and its rollback story does not hold once a project has written +`metadata.cmsMode`. + +Line numbers drift — treat them as pointers. + +--- + +## ~~PR 0 · Redact credentials from daemon console output~~ — cut, file separately + +**Not in scope.** It was justified by PR 5's "console always available during boot". With PR 5 cut, re-keying +the console gate from `fastPreviewEnabled` to "a pod exists" is a **no-op in practice** — CMS mode implies no +pod, so exactly the same people see the console in exactly the same situations as today. This work does not +promote the leak. + +**It is still a real bug and should be filed on its own:** +`clone.go:444` puts the clone URL (`https://x-access-token:ghs_…@github.com/…`) in argv, `:92` echoes argv +verbatim via `OnChunk`, and `events/broadcast.go:94` also appends it to the **replay buffer**, so it is +readable after boot by anyone with workspace access. Fix is `stripCredentials` +(`internal/setup/install.go:23`) applied in `formatArgv`, failing closed, asserted on the emitted bytes in +`packages/sandbox/daemon-e2e/` (CLAUDE.md #8). Independent of this plan in both directions. + +--- + +## PR 1 · Rename, all four workspaces + +Mechanical, boring, and reviewable *because* it is boring. **Not** "no behaviour change" — it touches an +authorization input. + +- Put the dual-read in **`packages/shared`** (beside `resolvePreviewServerUrl`, whose `productionUrl` alias + is the precedent) so web *and* api inherit it. `resolveFastPreview` → `resolveCmsMode`. +- **Consume it from all four current copies of the gate.** It is not "in ONE place" as its own doc claims: + - `apps/web/src/sdk/fast-preview.ts` (the helper) + - `apps/web/src/components/sandbox/preview/preview.tsx:373` — **inlines the gate**, never imports the + helper. Fix this first or the rename skips the file PRs 3–4 edit most. + - `apps/api/src/api/routes/decofile.ts:124` + - `apps/api/src/api/routes/sandbox-proxy.ts:213` +- **Keep writing `fastPreview` in this PR.** Read both, write old. The write flips only once every reader + ships. The metadata object is `.loose()`, so a premature `cmsMode` write type-checks and fails only against + a real server. +- Add `cmsMode` to the schema (`packages/shared/src/sdk/types/virtual-mcp.ts` — 3 sites) and run + `bun run --cwd=apps/api generate:tool-contracts`. +- i18n: rename keys **and retranslate pt-br values** — `bun run check` proves key completeness, not that + `pt-br/sandbox.ts:542` stopped saying "Preview Rápido". +- Leave `FAST_PREVIEW_CACHE_DIR` (`apps/api/src/decofile/disk-cache.ts`) alone — it is a deploy-config env + var. State this explicitly so it does not read as an oversight. +- **New e2e:** write `cmsMode` only, then hit the decofile route. The existing suite seeds `fastPreview` + directly (`packages/e2e/tests/decofile-api.spec.ts:161`), so it cannot catch this class of break. + +**Tests to update:** `blocks-tab-state.test.ts`, `preview-display.test.ts`, `section-preview-url.test.ts`, +`sandbox-lifecycle-context.test.ts`, `decofile-api.spec.ts` (asserts the literal 404 string at `:265`). + +--- + +## PR 2 · Widen `SidePanelKind` — all of it + +`use-layout-state.ts:24` → `"chat" | "cms"`. The type is the easy half; **six** files hardcode the literal, +and three fail silently rather than at compile time: + +| File | Line | Failure | +| --- | --- | --- | +| `router.tsx` | 298 | zod `"chat" \| 0` — **`?sidepanel=cms` is rejected** | +| `lib/thread-layout-memory.ts` | 27, 43 | **silently drops** the kind from layout memory | +| `chat/hooks/use-chat-navigation.ts` | 47 | **silently closes** the panel on thread navigation | +| `layouts/resolve-task-switch-search.ts` | 60 | compile error | +| `layouts/shell-layout.tsx` | 97, 119 | compile error | +| `main-panel-tabs/mobile-main-panel-tab-select.tsx` | 142 | hardcoded | + +Also mode-aware the three hardcoded `"chat"` defaults — `withWorkspaceFallback` (`:93`), +`resolveDefaultPanelState` (`:111`), and `resolveMobileSurface` (`:180`), which the first draft missed. +`MobileWorkspaceSurface = SidePanelKind | "main"` widens for free. + +`withWorkspaceFallback` is **module-private and not in the test file** — test it through +`resolveDefaultPanelState` rather than exporting it (keeps knip quiet). + +Parse unknown values to the union at the boundary; unit-test that `?sidepanel=junk` degrades to the default. +The rollback story assumes this and nothing currently provides it. + +**Tests:** update `use-layout-state.test.ts` (all 10 cases), add round-trip cases for +`use-chat-navigation` + `thread-layout-memory` — the two silent ones. + +--- + +## PR 3 · De-duplicate before building + +No behaviour change. Each item shrinks a later PR. + +1. **Delete `ContentBrowser`'s dead `mode="blocks"` path.** `content-tab.tsx:31` renders `` + with no `mode`, so ~10 branches (`content-browser.tsx:351, 357, 364, 378, 1032, 1050, 1117, 1340` + the + prop at `:243-247, 298, 336`) are unreachable. **`knip` will not find this** — it reports unused exports, + not unreachable prop branches. +2. **Extract `redirectIfInvisible()`** from `use-main-panel-tabs.ts:310-319`, where the `git` condition is + written twice and `content` is handled in one arm but not the other. Cover `git`/`content`/`code` + uniformly. There is **no `use-main-panel-tabs.test.ts`** — extracting is what makes this testable. +3. **Extract a shared ``** — the `lazy(() => import(sections-editor))` wrapper and the + `page:${k}` / `section:${k}` key builder are written twice (`blocks-panel.tsx:26-30, 110-114` and + `content-browser.tsx:145-149, 1315-1319`), and have already drifted: + `onVariantPreviewOverride` is passed by only one. + +--- + +## PR 4 · The CMS panel moves into the side panel + +Gated to projects where CMS mode is available (`resolveCmsMode(metadata).active`) — **not** to every project +with content, per the spec's corrected prerequisite. + +- `workspace-panel-group.tsx:324` — replace `FastPreviewChatNotice` with `BlocksPanel`. + `BlocksPreviewWorkspaceProvider` already sits above the panel group (`agent-shell-layout/index.tsx:326`), + so no state lifting. +- Add `CmsToggle` beside `ChatToggle`; carry `disableActiveSidePanelToggle` as `ChatToggle` does. +- **`resolveBlocksTabState` must be re-keyed too** (`blocks-tab-state.ts:44, 92`). It takes the gate as an + explicit input and was missing from the first draft's list — without it the panel renders a permanent + spinner. +- **Do not delete `chat.input.fastPreviewComingSoon`** — `input.tsx:644` still uses it. Delete only the + component; the key is re-copied in PR 5. +- **Move the CMS tour anchor.** `TOUR_ANCHORS.edit` is the tour's readiness gate + (`cms-tour.tsx:40` `READY_SELECTOR`) and lives on the button PR 5 deletes. Move it to `CmsToggle`; update + `steps.ts` + `steps.test.ts`. `` renders at `workspace-panel-group.tsx:292` — the file this PR edits. +- **Relocate `BlocksPanel`'s state components** (`MainPanelLoading`, `BlocksEmptyState`, `BlocksErrorState`) + out of `layouts/main-panel-tabs` — side-panel content should not depend on main-panel layout modules. +- ~~Disable `onActivate` in CMS mode.~~ **Dropped — #6054 solved it.** CMS mode no longer mounts + `HeaderActions` at all (`header-info.tsx:28-32`), so `send()` / `openSidePanel("chat")` is unreachable there. +- Tabs, console and preview origin follow **pod presence**, not the panel — so no tab-redirect logic is + needed here, and a developer opening the CMS panel keeps Code and Review changes. +- Follow the **mount-boundary pattern** from `header-info.tsx`: branch and mount a different component, + rather than threading a mode input into a shared one. `workspace-panel-group.tsx:324` already has the + identical shape. +- Reuse **`isCmsStateSettling`**'s rule for the panel's save indicator. Note `use-save-block.ts` / + `use-delete-block.ts` now **await** their status invalidation (changed in #6054, shared with vibecoding), + so the indicator already stays lit until the re-read lands. + +> The 320px floor already exists (`workspace-panel-group.tsx:297` +> `[&>[data-workspace-panel-open]]:!min-w-[320px]`). The first draft's 250px measurement tested an +> unreachable width. Drop the per-kind width storage — speculative, and it forks a localStorage key with no +> migration. + +**Click-through selection is deferred to PR 5** — it depends on the origin decision, which is a security +question, not a refactor. See the spec's open question 1. + +--- + +## PR 5 · The boot flow and the origin decision + +> **Recommend cutting.** Two critics called it speculative; I kept it because it was explicitly asked for. +> #6054 has since made it *harder*, not easier, and that tips the balance. +> +> The shipped design makes CMS ⟹ no pod structural: `header-info.tsx` branches at the mount specifically so +> that lifecycle hooks never mount on a CMS surface, and `SelectCmsHeaderButtonInput` has no pod input at +> all. "CMS project that also boots a pod" now means unwinding a deliberate, tested, merged decision — +> and the CMS header would have to grow a pod concept it was just designed to be free of. +> +> The cheaper path to the same user need is the **handoff**: a CMS project that needs code hands off to a +> vibecoding surface, rather than growing one in place. Keep the section below as the record of what state 2 +> would cost; do not build it without a fresh decision. + +Behind its own **default-off flag** (CLAUDE.md checklist #7 — this is a boot/dispatch hot path). + +- Start prompt → user-driven `lifecycle.start()`. Precedent: `content-browser.tsx:270`. +- Intent must not re-dress the workspace: while the pod is cold, tabs/console/origin stay as they are. +- Add an explicit **stop** control to the switch — gating the console on pod presence removes the preview + drawer, today the only `onStop` for hosted pods. +- Drop the preview SSE when in CMS mode with no pending boot (`agent-shell-layout/index.tsx:283`), so a CMS + tab stops renewing the pod claim every 5 minutes. +- Touch `shouldAutoStart` (`sandbox-lifecycle-context.tsx:66`) — it still auto-boots for non-gated projects, + contradicting rule 1. +- Guard `start()` on `startVm.isPending` (`:749`), which the auto-start path already does. +- Re-gate `input.tsx:642` on pod absence, with **new copy** — the key's meaning changes. +- **Keep `draftPreviewUrl` keyed on the metadata gate**, not the mode. It is why entering vibecoding is not + an iframe remount; re-keying it "for consistency" would turn that into a cross-origin navigation. +- Retire the nested pane + `Edit content` toggle (`preview.tsx:1845-1863, 1244`), and the **four** + `activateEditingMode("blocks")` callers (`:999, 1164, 1215, 1643`). Collapse `PreviewEditingMode` to + `"preview" | "visual"` and retire `cmsDefaultOpen` / `shouldAutoOpenCms` with it. Then `knip`. +- **The origin decision** (spec open question 1) — if taken: re-derive `previewOrigin` from the iframe's + actual base, `null` during swaps, add the `e.source` check, and add an e2e asserting a wrong-origin message + is rejected. + +--- + +## Testing + +Two tiers, no third ([`TESTING.md`](../../../TESTING.md)). + +**Unit** — all proposed tests are over genuinely pure functions; nothing needs `mock.module` or a stubbed +context. Write phase cases against the **real** `LifecycleState["phase"]` union +(`idle | cloning | checking-out | installing | starting | running | crashed | clone-failed | install-failed | start-failed`) +— the first draft's `"cold"` does not exist. + +Add: `redirectIfInvisible` for `git`/`content`/`code` covering **both** `activeTab` and `mainOpen` (the +existing asymmetry at `:316-319` is untested); `resolveCmsMode` legacy-key fallback; unknown search-param +degradation; `resolveWorkspacePanelAction` when the requested kind is unavailable. + +`blocks-preview-workspace-state.test.ts:41-52` asserts the whole state object with `toEqual` — adding +`sectionClick` breaks it. (The reducer is in `blocks-preview-workspace-state.ts`, not `-context.tsx`.) + +**E2E** — promote the CMS-project fixture out of `decofile-api.spec.ts:76-180` (per `TESTING.md:84`, second +use). Note `plugins/ban-e2e-app-imports.js` allows only `@playwright/test`, `pg`, `zod`, +`@modelcontextprotocol/sdk`, `@decocms/shared`. + +**Cases 5–7 of the first draft are not writable** — no e2e boots a pod; the suite has no sandbox provider or +lifecycle SSE source. Scope them to what is observable (start prompt appears; tabs/console unchanged; +confirming issues exactly one `SANDBOX_START`) and move phase transitions to unit tests, or budget a +lifecycle-stub fixture. + +**Inversions** — the first draft's three-string grep is insufficient. Files that break: +`use-layout-state.test.ts`, `source-system-tabs.test.ts`, `preview-display.test.ts`, +`blocks-tab-state.test.ts` (a whole `describe("sandbox-less Fast Preview")` whose premise this deletes), +`sandbox-lifecycle-context.test.ts`, `section-preview-url.test.ts`, `blocks-preview-workspace-state.test.ts`, +`decofile-api.spec.ts`, `standalone-blocks-panel.spec.ts`. Also `tab-id.test.ts:257`, which keeps passing +while its comment becomes a lie. + +--- + +## Critique decisions + +**Adopted** + +- Deleted `WorkspaceMode`, `resolveWorkspaceMode`, `?mode=`, `committed` and `devFrameReady`. All four + duplicated existing signals, `podPhase !== "cold"` referenced a non-existent phase, and + `devFrameReady === "running"` was *weaker* than `resolvePreviewDisplay`'s existing rule. +- Tabs/console follow **pod presence**, not mode — removes the tab-bounce bug and the redirect logic. +- CMS mode requires the metadata gate; `hasEditableDecoContent` is not the availability signal. +- Rename extended to `apps/api` + `packages/shared`, write stays on the old key. +- Split into five PRs; added PR 0 (credential redaction) and PR 3 (de-duplication). +- Added: `resolveBlocksTabState`, `shouldAutoStart`, the CMS tour anchor, the six `SidePanelKind` literal + sites, the stop control, the SSE/TTL renewal, `onActivate` in CMS mode, `draftPreviewUrl` staying keyed. +- Added a security section; corrected the rollback claim and the 250px width finding. +- Dropped per-kind panel width (speculative) and the first draft's unwritable e2e cases. + +**Rejected** + +- *"Cut the boot flow entirely."* Kept as PR 5 behind a default-off flag. It is the state the user explicitly + asked to design, and gating it is enough to bound the risk. +- *"Keep the `Content` tab out of the switch's concerns."* No change needed — it already stays. + +**The CMS exit ("the honest wall") — cut from this plan** + +Sketched four options; the wall was preferred. It is **not** in any PR here. It is pre-existing (the same +wall exists in today's nested pane), so moving the panel neither creates nor worsens it — and the contextual +version I sketched is not buildable, because the block form has no signal for *what the editor wanted*. See +the spec's [The wall](./cms-mode-spec.md#the-wall--noted-deliberately-not-solved-here). If picked up, it is a +standalone ~2-i18n-key change that applies to the old pane and the new one alike. + +**Revised again after PR #6054 merged** + +- Dropped the `onActivate` guard from PR 4 — the collision it addressed no longer exists. +- Adopted the **mount-boundary pattern** as the governing rule, replacing "thread a mode into shared + components". It was already the shape of PR 4; now it is precedent rather than invention. +- **Flipped my earlier rejection: PR 5 is now recommended for cutting.** The shipped code makes CMS ⟹ no pod + structural, so state 2 costs more than it did when I kept it. +- PR 1 shrinks slightly — the new files already use CMS vocabulary (`cms-panel-state`, `cms-header-actions`, + `thread.cmsActions.*`) while the gate is still `resolveFastPreview`, so the codebase is now *half*-renamed + and inconsistent. That raises the value of finishing it. +- Noted `use-save-block` / `use-delete-block` now await status invalidation (shared with vibecoding). + +**Adapted** + +- *"Restrict scope to Fast Preview projects."* Adopted in substance — CMS mode requires the gate — but the + **rename stands**, so the user-facing vocabulary is still two modes with no "Fast Preview" anywhere. The + gate becomes "has a preview server", not a feature flag. +- *"Delete `PreviewEditingMode`'s `blocks` value."* Deferred to PR 5 where its three callers are removed, + rather than done early. + +--- + +## Checklist + +- [ ] `bun run fmt` · `bun run lint` · `bun run check` · `bun test` · `knip` +- [ ] `bun run --cwd=apps/api generate:tool-contracts` (PR 1) +- [ ] pt-br **values** retranslated, not just keys renamed +- [ ] PR 5 ships default-off +- [ ] Screenshots: CMS mode, boot prompt, booting, vibecoding, unavailable-CMS project diff --git a/apps/web/docs/cms-mode-spec.md b/apps/web/docs/cms-mode-spec.md new file mode 100644 index 0000000000..1093a37df5 --- /dev/null +++ b/apps/web/docs/cms-mode-spec.md @@ -0,0 +1,209 @@ +# CMS mode / Vibecoding mode — spec + +**Status:** proposed, revised after critique · **Scope:** `apps/web` shell, preview, side panel · `apps/api` gate + +> Revised after a 7-perspective review. Five load-bearing claims in the first draft were false against +> source. See [Critique decisions](./cms-mode-plan.md#critique-decisions) in the plan. + +## Summary + +Studio's editing workspace has two audiences and one undifferentiated UI. This spec gives it two modes: + +| Mode | For | Cost | +| --- | --- | --- | +| **CMS mode** | Content editors. Blocks, copy, images, page layout. | Free — *when the project has a preview server* | +| **Vibecoding mode** | Developers. Components, logic, dependencies. | A pod, ~1 min boot | + +"Fast Preview" is retired as a **name**. It survives as a **prerequisite**: CMS mode is only pod-less on +projects with a preview server URL, because that is the only configuration where the decofile is reachable +over HTTP instead of through the sandbox daemon. + +--- + +## The prerequisite (corrected) + +The first draft claimed CMS mode is free wherever a site has content. **That is false**, and it is the +correction that most changes the design. + +The pod-less CMS data path exists only behind the persisted gate: + +| Operation | Gate on | Gate off | +| --- | --- | --- | +| Read decofile | `fetchDecofile` — GitHub API (`use-decofile.ts:55`) | `readCommittedJson` — **through the daemon** | +| Write block | `patchDecofile` — GitHub API (`use-save-block.ts:51`) | `POST …/sandbox/…/write` — **the pod's filesystem** | +| Panel state | bypasses lifecycle (`blocks-tab-state.ts:44`) | `classifyPhase("idle")` → `loading`, **forever** | + +Server-side the same gate guards the route CMS mode runs on: `decofile.ts:124` 404s without it, and +`sandbox-proxy.ts:213` needs it to answer `/git/*` from GitHub. + +Two consequences: + +1. **CMS mode requires a preview server URL.** Without one there is no free mode — the honest product answer + is "set a preview server to enable CMS mode", surfaced in settings, not a mode that spins forever. +2. **`hasEditableDecoContent` cannot be the availability signal.** It is derived from the decofile, which off + the gate needs a pod. You would have to boot to discover you did not need to. Availability is the + **metadata gate**; content presence only decides whether the panel has anything to show. + +``` +cmsModeAvailable = resolveCmsMode(metadata).active // cmsMode|fastPreview && previewServerUrl +``` + +--- + +## Model — derived, not invented + +The first draft added a `WorkspaceMode` enum, a `?mode=` search param, and `committed` / `devFrameReady` +booleans. **All four are deleted.** Every signal already exists: + +| Concept | Source of truth | +| --- | --- | +| Which mode the user wants | `SidePanelKind` — `"chat" \| "cms"`, already in the URL | +| Is there a pod | `vmEntry` in the sandbox lifecycle — the same predicate `shouldAutoStart` uses | +| Is the dev server showable | `resolvePreviewDisplay` — keep its `progressStatus !== "doing"` rule, which deliberately admits `failed`/`crashed` so the daemon status page renders | +| Is CMS worth offering | `resolveCmsMode(metadata).active` | + +One intent bit, already in the URL, already persisted per thread. A second `?mode=` param would be a second +copy of the same intent that must be kept in lockstep — and `?mode=code&sidepanel=cms` would be constructible. + +**Mode is `sidePanel === "cms" ? "cms" : "code"`.** Nothing more. + +### Tabs and console follow the pod, not the panel + +The first draft keyed tabs on the mode. That is a state-loss bug: a developer with a pod running who opens +the CMS panel to fix a paragraph would lose Code and Review changes, and get bounced off whichever tab they +were on. + +- **Code · Review changes · console** ← **a pod exists** +- **Side panel contents** ← `SidePanelKind` +- **Preview origin** ← `resolvePreviewDisplay`, unchanged rules + +This removes the redirect logic the first draft needed, and fixes the bounce for free. + +--- + +## Rules + +### In scope + +1. **CMS mode requires a preview server URL** — see [The prerequisite](#the-prerequisite-corrected). +2. **No switch when CMS is unavailable.** The control disappears rather than showing a disabled half. +3. **Side panel contents follow `SidePanelKind`; tabs and console follow pod presence.** A developer with a + pod who opens the CMS panel keeps Code and Review changes — the panel kind must not bounce them off a tab. + +### Parked with PR 5 (the boot flow) + +These only bind once a CMS project can boot a pod. Recorded so they are not rediscovered late. + +4. **Entering vibecoding is explicit and costed.** Never auto-start on a stray click — `shouldAutoStart` is + gated because an accidental start once leaked one pod per new chat (`sandbox-lifecycle-context.tsx:50`). +5. **Cold vs. warm is visible on the switch**, and the warm dot is the **stop** control — see rule 6. +6. **Leaving vibecoding does not kill the pod, but the user must be able to.** Gating the console on pod + presence would remove the preview drawer, which today holds the only `onStop` for hosted pods + (`preview-drawer-host.tsx:115`). *No regression today, because CMS mode has no pod to stop.* +7. **A CMS tab should not hold a pod claim open.** The preview SSE calls `renewTtl` every 5 minutes + (`sandbox-events-handler.ts:124`), extending shutdown for as long as a tab is open. *Only reachable if a + CMS tab can coexist with a pod.* +8. **CMS keeps working while a pod runs — with one caveat.** Reads go to the GitHub branch head while the + agent edits the working tree. Two content sources on one screen; which wins is an open question. + +--- + +## Security + +**For PRs 1–4: nothing new.** The panel move changes which column renders the block editor. It does not +touch the preview iframe's origin, does not enable canvas click-through, and does not promote the console. +Each of those was a consequence of the boot flow, which is cut. + +The three findings below are **gates on PR 5**, recorded so they are not rediscovered late if it is revived. +The first is also a live bug worth filing independently of this work. + +**Daemon console leaks a credential.** `clone.go:444` puts the clone URL in argv, `:92` echoes argv verbatim, +and `broadcast.go:94` keeps it in the replay buffer — so `https://x-access-token:ghs_…@github.com/…` is +readable after boot. **Pre-existing and not promoted by this work** (CMS mode implies no pod, so the console +is shown to exactly the same people as today). File it on its own. + +**The origin trust boundary — a gate on click-through.** `previewOrigin` derives the postMessage allow-list +from the *sandbox* URL (`preview.tsx:876`), and `preview.tsx:1981` deliberately skips editor injection for +non-sandbox frames: *"the production fallback is a view-only, cross-origin frame."* With no pod, +`previewOrigin` returns `null` (`:194-200`) and both the listener and the injection are already inert — so +**click-through does not work in CMS mode today, and PR 4 shipping list-driven-only is not a regression.** +Making it work means injecting `CMS_EDITOR_SCRIPT` into the customer's production origin and trusting +messages back: a real trust-boundary expansion, an explicit decision, never a refactor side effect. If taken, +derive `previewOrigin` from the URL **currently in the iframe** (five call sites: `:876, 915, 925, 932, 954`), +`null` during swaps, never `"*"`, and add `e.source === previewIframeRef.current?.contentWindow`. + +**Authorization — a gate on the boot button.** `SANDBOX_START` sits under `basic-usage`, so any org member +can provision compute. Only matters once a boot button is put in front of editors. + +--- + +## Open questions + +1. **Do we take the injection trust-boundary expansion?** If not, canvas click-through does not work in CMS + mode and the panel is list-driven only. +2. **Which content source wins** when the gate is on and a pod is running — GitHub branch head or the + working tree? +3. **Per-org concurrent-pod cap.** None exists. Out of scope, but it should be a ticket. + +## The wall — noted, deliberately not solved here + +An editor who needs a field that does not exist cannot get it in CMS mode, and the gate is per-project +(`header-info.tsx:22`), so there is no sibling code thread to hand off to. Decision: **out of scope**, on two +grounds. + +**It is pre-existing.** Editors hit the identical wall today in the nested blocks pane. Moving the panel +neither creates nor worsens it, so it fails the scope test even though it is a real gap. + +**The responsive version is not buildable.** A message that reacts to *what the editor wanted* needs an +intent signal, and the block form has none — it renders the fields that exist and never learns which one you +wished for. Only a static, always-on note is implementable without adding a prompt box, i.e. without adding +the chat CMS mode does not have. + +If it is picked up later it is its own change, independent of the panel move and applying equally to the old +pane and the new one: a static note on the block form plus two i18n keys, optionally deep-linking to the +component via `__resolveType` (`parse-sections.ts:31`). CMS mode already ships `viewOnGithub` / +`resolveOnGithub`, so an external link is the established shape. + +## The governing pattern: branch at the mount, don't thread a mode + +PR #6054 (merged) settled this, and the code says why: + +> *"Fast Preview swaps in the CMS renderer **here, not inside `HeaderActions`**, so the sandbox hooks that +> renderer mounts (events, lifecycle, publish gate) never mount on a surface that has no sandbox."* +> — `views/virtual-mcp/header-info.tsx:11-15` + +So the rule is **not** "thread a `mode` parameter into shared components". It is: at the boundary, mount a +different component. `SelectCmsHeaderButtonInput` takes branch, PR, checks, reviews and in-flight flags — +**no pod, no lifecycle, no mode enum**. The gate stays `resolveCmsMode(metadata).active`, read at mount +points. + +This is simpler than the first draft's "re-key the gates" approach and it is already precedent. Where a +surface cannot be swapped wholesale (the preview canvas), keep the existing pure function's own rules and +change only its inputs — never invent a parallel one. + +**Consequence for rule 7.** The shipped design assumes CMS ⟹ no pod, structurally, at the mount. A CMS +project that also runs a pod is now *harder* than before #6054, not easier — see the plan's note on PR 5. + +## Already solved by #6054 + +- **The header action bar in CMS mode.** `CmsHeaderActions` + `cms-panel-state.ts` (7 states, ~90 unit + tests) replace the 21-state vibecoding machine. The five dead-click actions are gone. +- **The `send()` / `openSidePanel("chat")` collision** the first draft called "not free". CMS mode never + mounts `HeaderActions`, so there is nothing to disable. Dropped from scope. +- **`usePublishGate` and its 10s GitHub poll** are off the CMS path entirely. + +## Reusable from #6054 + +- **`isCmsStateSettling()`** and its rule — *never render a confident state from data a pending operation is + about to change; every busy flag spans its own follow-up read*. This applies directly to the mode switch + and any boot UI. +- **`SplitButton`** (`packages/ui`) — `disabled` disables only the primary half, so an inert pill can still + offer menu actions. +- **`isCheckFailed` / `isCheckInProgress`**, newly exported from `panel-state.ts`. + +## Out of scope + +- **The `Content` main-panel tab.** Stays — `ContentBrowser` browses everything; the side panel edits the + current page. +- **The sidebar thread list.** Unchanged. +- **The vibecoding header bar.** Untouched by this work. diff --git a/apps/web/src/components/chat/hooks/use-chat-navigation.ts b/apps/web/src/components/chat/hooks/use-chat-navigation.ts index 03c4ad69d3..616864603b 100644 --- a/apps/web/src/components/chat/hooks/use-chat-navigation.ts +++ b/apps/web/src/components/chat/hooks/use-chat-navigation.ts @@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useProjectContext } from "@/sdk"; import { isPerThreadTab } from "@/layouts/main-panel-tabs/tab-id"; import { AUTOSEND_QUERY_VALUE } from "@/lib/autosend"; +import { parseSidePanelKind } from "@/hooks/use-layout-state"; export interface ChatNavigation { /** Resolved vMCP for the current chat — either the URL param or the well-known decopilot. */ @@ -44,8 +45,11 @@ export function useChatNavigation(): ChatNavigation { !isPerThreadTab(prevMain) ) next.main = prevMain; - if (prev.sidepanel === "chat" || prev.sidepanel === 0) { - next.sidepanel = prev.sidepanel; + if (prev.sidepanel === 0) { + next.sidepanel = 0; + } else { + const kind = parseSidePanelKind(prev.sidepanel); + if (kind) next.sidepanel = kind; } if (opts?.autosend) next.autosend = AUTOSEND_QUERY_VALUE; return next; diff --git a/apps/web/src/components/chat/input.tsx b/apps/web/src/components/chat/input.tsx index d99d3dfd7a..9233fc3f69 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -15,7 +15,7 @@ import { useProjectContext, useVirtualMCP, } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { useNavigate } from "@tanstack/react-router"; import { ArrowUp, @@ -386,7 +386,7 @@ export function ChatInput({ const { org, locator } = useProjectContext(); const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; const selectedVm = useVirtualMCP(selectedVirtualMcp?.id); - const fastPreviewActive = resolveFastPreview(selectedVm?.metadata).active; + const cmsModeActive = resolveCmsMode(selectedVm?.metadata).active; const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); const { unsupportedFile, onUnsupportedFile, clearUnsupportedFile } = @@ -639,10 +639,8 @@ export function ChatInput({ // a sandbox runner — a message would hang against a runner that will never // exist. Hold the composer with an honest notice until the agent learns to // work through the decofile API (or per-thread sandbox fallback lands). - if (fastPreviewActive) { - return ( - - ); + if (cmsModeActive) { + return ; } return ( diff --git a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx index d4d5a201df..c3bbb2192d 100644 --- a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy } from "react"; import { Loading01 } from "@untitledui/icons"; import { useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { useChatTask } from "@/components/chat/context"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; @@ -68,7 +68,7 @@ export function BlocksPanel({ decofile: toBlocksQueryState(decofile), meta: toBlocksQueryState(meta), hasEditableContent: hasEditableDecoContent(decofile.data, meta.data), - fastPreviewActive: resolveFastPreview(vmcp?.metadata).active, + cmsModeActive: resolveCmsMode(vmcp?.metadata).active, }); if (state.kind === "loading") return ; diff --git a/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx b/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx index 6d022d9707..a8380c3cde 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx @@ -31,7 +31,7 @@ import { } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { KEYS, invalidateVirtualMcpQueries } from "@/lib/query-keys"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; @@ -219,10 +219,10 @@ export function SandboxEventsProvider({ // from disk, and a refetch could revert an optimistic edit against a committed // `blocks.gen.json`. const vmcp = useVirtualMCP(virtualMcpId ?? undefined); - const fastPreviewActive = resolveFastPreview(vmcp?.metadata).active; - const fastPreviewActiveRef = useRef(fastPreviewActive); + const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; + const cmsModeActiveRef = useRef(cmsModeActive); // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- keep the SSE closure reading the latest value without reconnecting - fastPreviewActiveRef.current = fastPreviewActive; + cmsModeActiveRef.current = cmsModeActive; const [phase, setPhase] = useState(null); const [lifecycle, setLifecycle] = useState({ phase: "idle" }); const [status, setStatus] = useState({ state: "running" }); @@ -471,7 +471,7 @@ export function SandboxEventsProvider({ // visibly revert; leave the optimistic cache as the source of // truth until the lifecycle→running transition re-invalidates. const devServerRunning = prevLifecyclePhase === "running"; - if (!devServerRunning && !fastPreviewActiveRef.current) return; + if (!devServerRunning && !cmsModeActiveRef.current) return; // Turn on the preview's loading overlay immediately — before the // debounce below — so the pending refresh feels instant instead of // only appearing once the reload finally fires. diff --git a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts index 393c956ee2..b3f863ce56 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts @@ -106,7 +106,7 @@ describe("shouldAutoStart", () => { userStopped: false, isPending: false, attempted: false, - fastPreviewActive: false, + cmsModeActive: false, }; test("all conditions met → true", () => { @@ -114,7 +114,7 @@ describe("shouldAutoStart", () => { }); test("fast preview active → false (sandbox-less mode never auto-boots)", () => { - expect(shouldAutoStart({ ...base, fastPreviewActive: true })).toBe(false); + expect(shouldAutoStart({ ...base, cmsModeActive: true })).toBe(false); }); test("disabled execution boundary → false", () => { diff --git a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx index 7b7f075214..cabfb48a6b 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -44,7 +44,7 @@ export interface ShouldAutoStartArgs { * through the decofile API and the preview renders against production, so * arriving at a branch must NOT boot a pod. A user-driven `start()` (e.g. * for the Code tab) still works — only the auto-start is gated. */ - fastPreviewActive: boolean; + cmsModeActive: boolean; } /** @@ -63,7 +63,7 @@ export interface ShouldAutoStartArgs { export function shouldAutoStart(args: ShouldAutoStartArgs): boolean { return ( args.executionEnabled && - !args.fastPreviewActive && + !args.cmsModeActive && args.hasActiveGithubRepo && !!args.userId && !!args.branch && @@ -361,7 +361,7 @@ import { useProjectContext, useVirtualMCP, } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import type { SandboxMap } from "@decocms/shared/sdk/types"; import { useQueryClient } from "@tanstack/react-query"; import { invalidateVirtualMcpQueries } from "@/lib/query-keys"; @@ -471,10 +471,10 @@ export function SandboxLifecycleProvider({ const events = useSandboxEvents(); const queryClient = useQueryClient(); // Sandbox-less mode: Fast Preview projects never auto-provision a pod (see - // ShouldAutoStartArgs.fastPreviewActive). Self-heal/claim-retry stay ungated — + // ShouldAutoStartArgs.cmsModeActive). Self-heal/claim-retry stay ungated — // they only ever fire for a sandbox that already exists. const vmcp = useVirtualMCP(virtualMcpId ?? undefined); - const fastPreviewActive = resolveFastPreview(vmcp?.metadata).active; + const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; const mcpClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -601,7 +601,7 @@ export function SandboxLifecycleProvider({ userStopped, isPending: startVm.isPending, attempted, - fastPreviewActive, + cmsModeActive, }); // oxlint-disable-next-line ban-use-effect/ban-use-effect -- bridges external state into a one-shot mutation; no render-time equivalent useEffect(() => { diff --git a/apps/web/src/components/sandbox/preview/preview-display.test.ts b/apps/web/src/components/sandbox/preview/preview-display.test.ts index 8f476bba9b..1076a80dbc 100644 --- a/apps/web/src/components/sandbox/preview/preview-display.test.ts +++ b/apps/web/src/components/sandbox/preview/preview-display.test.ts @@ -25,8 +25,8 @@ function run(overrides: Partial) { previewState: STARTING, progressStatus: "doing", previewServerUrl: PROD, - fastPreviewActive: false, - fastPreviewReady: false, + cmsModeActive: false, + cmsModeReady: false, ...overrides, }); } @@ -118,8 +118,8 @@ describe("resolvePreviewDisplay", () => { run({ previewState: IFRAME, progressStatus: "doing", - fastPreviewActive: true, - fastPreviewReady: true, + cmsModeActive: true, + cmsModeReady: true, }), ).toEqual({ mode: "production", @@ -137,8 +137,8 @@ describe("resolvePreviewDisplay", () => { run({ previewState: STARTING, progressStatus: "doing", - fastPreviewActive: true, - fastPreviewReady: false, + cmsModeActive: true, + cmsModeReady: false, }), ).toEqual({ mode: "production", @@ -154,8 +154,8 @@ describe("resolvePreviewDisplay", () => { const result = run({ previewState: IFRAME, progressStatus: "doing", - fastPreviewActive: true, - fastPreviewReady: false, + cmsModeActive: true, + cmsModeReady: false, }); expect(result.mode).toBe("production"); expect(result.iframeBase).toBe(PROD); @@ -167,8 +167,8 @@ describe("resolvePreviewDisplay", () => { const result = run({ previewState: IFRAME, progressStatus, - fastPreviewActive: true, - fastPreviewReady: true, + cmsModeActive: true, + cmsModeReady: true, }); expect(result.mode).toBe("production"); expect(result.iframeBase).toBe(PROD); @@ -181,8 +181,8 @@ describe("resolvePreviewDisplay", () => { const result = run({ previewState: IFRAME, progressStatus: "done", - fastPreviewActive: true, - fastPreviewReady: false, + cmsModeActive: true, + cmsModeReady: false, }); expect(result.mode).toBe("production"); expect(result.showWakingPill).toBe(true); diff --git a/apps/web/src/components/sandbox/preview/preview-display.ts b/apps/web/src/components/sandbox/preview/preview-display.ts index cd8ab56662..27bc2158e2 100644 --- a/apps/web/src/components/sandbox/preview/preview-display.ts +++ b/apps/web/src/components/sandbox/preview/preview-display.ts @@ -55,14 +55,14 @@ export interface PreviewDisplayInput { * Preview swaps in the daemon's draft render (ready after the clone) where the * normal path waits for the dev server (ready at `running`). */ - fastPreviewActive?: boolean; + cmsModeActive?: boolean; /** * The caller could actually build the draft URL — it has the sandbox handle - * and a draft version. Only meaningful with `fastPreviewActive`. False means + * and a draft version. Only meaningful with `cmsModeActive`. False means * the draft isn't addressable yet, so the published site keeps the canvas * (with the waking pill) instead. */ - fastPreviewReady?: boolean; + cmsModeReady?: boolean; } const NONE: PreviewDisplay = { @@ -81,8 +81,8 @@ export function resolvePreviewDisplay( previewServerUrl, // Optional: a caller that knows nothing about Fast Preview gets exactly // the pre-existing behaviour. - fastPreviewActive = false, - fastPreviewReady = false, + cmsModeActive = false, + cmsModeReady = false, } = input; // Suspended / errored render their own dedicated card — hand the canvas over @@ -97,7 +97,7 @@ export function resolvePreviewDisplay( // skip. `iframeBase` stays the PUBLISHED url: the caller layers the draft URL // over it, so every production-mode base is a page we can actually navigate to // (and the URL-bar label doesn't jump between origins mid-boot). - if (fastPreviewActive && fastPreviewReady && previewServerUrl) { + if (cmsModeActive && cmsModeReady && previewServerUrl) { return { mode: "production", iframeBase: previewServerUrl, @@ -112,7 +112,7 @@ export function resolvePreviewDisplay( // not behind a "waking" pill. Fast Preview skips this branch entirely — its // draft render above owns the canvas instead of the dev server. if ( - !fastPreviewActive && + !cmsModeActive && previewState.kind === "iframe" && progressStatus !== "doing" ) { diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx index 803d2e5065..733dacee00 100644 --- a/apps/web/src/components/sandbox/preview/preview.tsx +++ b/apps/web/src/components/sandbox/preview/preview.tsx @@ -12,7 +12,7 @@ import { useInsetContext } from "@/layouts/agent-shell-layout"; import { resolvePreviewDisplay } from "./preview-display"; import { useIframeLoadRecovery } from "./preview-iframe-recovery"; import { buildPreviewLabel } from "./preview-label"; -import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { useIsMobile } from "@decocms/ui/hooks/use-mobile.ts"; import { useT } from "@/i18n/use-t.ts"; import type { TranslationKey } from "@/i18n/use-t.ts"; @@ -85,7 +85,7 @@ import { import { decoBlockFileViewPath } from "@/components/sections-editor/deco-block-key"; import { findLivePageResolveType } from "@/components/sections-editor/section-catalog"; import { - buildFastPreviewDraftUrl, + buildCmsDraftUrl, buildGlobalSectionPreviewUrl, } from "@/components/sections-editor/section-preview-url"; import { @@ -360,18 +360,13 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // of a blank overlay. `null` (no field, or a site imported before this was // persisted) → the original blocking overlay is kept. const inset = useInsetContext(); - const previewServerUrl = + // Scoped to THIS agent's entity; the shared helper owns the gate itself. + const cmsGate = inset?.entity?.id === virtualMcpId - ? resolvePreviewServerUrl(inset.entity.metadata) - : null; - // Fast Preview (opt-in switch in CMS settings): sandbox-less mode — the - // draft is the branch head served by the decofile API, rendered against - // `previewServerUrl`. Requires BOTH the switch and a production URL — a bare - // flag is inert (nothing to render against), and `previewServerUrl` is non-null - // only for this agent's entity, so reading `metadata.fastPreview` off the - // same object is safe. - const fastPreviewEnabled = - !!previewServerUrl && inset?.entity?.metadata?.fastPreview === true; + ? resolveCmsMode(inset.entity.metadata) + : { previewServerUrl: null, active: false }; + const previewServerUrl = cmsGate.previewServerUrl; + const cmsModeEnabled = cmsGate.active; // Decofile pages/global sections for the URL bar dropdown. Not gated on the // dev server: when it's down we read the committed `.deco/*.gen.json` snapshot @@ -399,7 +394,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { decofile: toBlocksQueryState(decofileQuery), meta: toBlocksQueryState(metaQuery), hasEditableContent: hasEditableDecoContent(decofile, meta), - fastPreviewActive: fastPreviewEnabled, + cmsModeActive: cmsModeEnabled, }).kind === "content"; const createPageParams = virtualMcpId && branch ? { orgSlug: org.slug, virtualMcpId, branch } : null; @@ -551,7 +546,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // Computed BEFORE `display`: it is an input to that decision, so it must not // depend on `display.mode` in turn. const decofileDraft = useDecofileDraft( - fastPreviewEnabled && virtualMcpId && branch + cmsModeEnabled && virtualMcpId && branch ? { orgSlug: org.slug, virtualMcpId, branch } : null, ); @@ -570,12 +565,12 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { ), }) > 0; const draftPreviewUrl = - fastPreviewEnabled && + cmsModeEnabled && previewServerUrl && decofileDraft && virtualMcpId && branch - ? buildFastPreviewDraftUrl({ + ? buildCmsDraftUrl({ previewServerUrl, apiHost: decofileDraft.apiHost, orgSlug: org.slug, @@ -597,8 +592,8 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { previewState, progressStatus: progress.status, previewServerUrl, - fastPreviewActive: fastPreviewEnabled, - fastPreviewReady: !!draftPreviewUrl, + cmsModeActive: cmsModeEnabled, + cmsModeReady: !!draftPreviewUrl, }); const previewSurfaceActive = display.mode !== "none"; @@ -862,12 +857,16 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { !appPaused && (claimPhase?.kind === "ready" || lifecyclePhase !== "idle"); - // Visual mode requires the live sandbox iframe — the production fallback is a - // different origin we can't inject into. Blocks can stay open while the - // sandbox restarts (or wakes) so its loading/error state remains actionable - // and the panel keeps reading the committed snapshot. + /** + * Visual mode requires the live sandbox iframe — the production fallback is a + * different origin we can't inject into. Blocks can stay open while the + * sandbox restarts (or wakes) so its loading/error state remains actionable + * and the panel keeps reading the committed snapshot — except in CMS mode, + * where the side panel owns the block editor and this pane would duplicate it. + */ const effectiveEditingMode: PreviewEditingMode = - display.mode !== "sandbox" && editingMode === "visual" + (display.mode !== "sandbox" && editingMode === "visual") || + (cmsModeEnabled && editingMode === "blocks") ? "preview" : editingMode; @@ -1241,7 +1240,8 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // it reads as a distinct action rather than being wedged inside the URL // controls. On mobile it anchors the left edge on its own (see below). // Filled when the Blocks editor is open; click again for plain preview. - const cmsToggle = showPreviewToolbar ? ( + const showCmsToggle = showPreviewToolbar && !cmsModeEnabled; + const cmsToggle = showCmsToggle ? ( @@ -1839,23 +1839,25 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { orientation="horizontal" disabled={effectiveEditingMode !== "blocks"} > - - {effectiveEditingMode === "blocks" && ( - - )} - - {effectiveEditingMode === "blocks" && ( + {!cmsModeEnabled && ( + + {effectiveEditingMode === "blocks" && ( + + )} + + )} + {!cmsModeEnabled && effectiveEditingMode === "blocks" && ( )} { +/** + * The CMS-mode switch. Gated on the preview server URL: CMS mode renders the + * draft against that URL, so with none set there's nothing to render against — + * the switch stays disabled (and visually off) until one is provided. + * `previewServerUrl` is passed by the parent from + * `form.watch("metadata.previewServerUrl")` so the switch reacts to edits + * without this leaf owning the form type. Generic over the parent schema, + * mirroring `PreviewServerUrlField`. + * + * Writes the LEGACY `metadata.fastPreview` key on purpose — `resolveCmsMode` + * reads both, and the API gates still read `fastPreview`, so flipping the write + * before they ship would 404 the CMS for every newly-toggled project. + */ +export interface CmsModeFieldProps { control: Control; /** Current `metadata.previewServerUrl` value (watched by the parent). */ previewServerUrl: string | null | undefined; } -export function FastPreviewField({ +export function CmsModeField({ control, previewServerUrl, -}: FastPreviewFieldProps) { +}: CmsModeFieldProps) { const t = useT(); const hasPreviewServerUrl = !!sanitizeSiteUrl(previewServerUrl); return ( @@ -35,20 +41,17 @@ export function FastPreviewField({ render={({ field }) => (
-
{ +describe("buildCmsDraftUrl", () => { it("targets the real page on the production origin", () => { // Not /live/previews: the site renders its OWN route, so hydration and // in-preview navigation work. - const url = new URL( - buildFastPreviewDraftUrl({ ...SCOPE, path: "/blog/hello" }), - ); + const url = new URL(buildCmsDraftUrl({ ...SCOPE, path: "/blog/hello" })); expect(url.origin).toBe(PROD); expect(url.pathname).toBe("/blog/hello"); }); @@ -32,7 +30,7 @@ describe("buildFastPreviewDraftUrl", () => { // The runtime validates the authority against its configured preview-API // domains and derives the scheme itself; a full URL here would be the // SSRF surface the design exists to avoid. - const url = new URL(buildFastPreviewDraftUrl({ ...SCOPE, path: "/" })); + const url = new URL(buildCmsDraftUrl({ ...SCOPE, path: "/" })); expect(url.searchParams.get("__draft")).toBe( `studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc@${SCOPE.version}`, ); @@ -40,7 +38,7 @@ describe("buildFastPreviewDraftUrl", () => { it("keeps a local dev port in the authority", () => { const url = new URL( - buildFastPreviewDraftUrl({ + buildCmsDraftUrl({ ...SCOPE, apiHost: "localhost:4000", path: "/", @@ -53,7 +51,7 @@ describe("buildFastPreviewDraftUrl", () => { it("percent-encodes branch and virtualMcpId path segments", () => { const url = new URL( - buildFastPreviewDraftUrl({ + buildCmsDraftUrl({ ...SCOPE, branch: "feat/hero", path: "/", @@ -66,13 +64,13 @@ describe("buildFastPreviewDraftUrl", () => { it("changes with the version, so a save re-navigates the frame", () => { const at = (version: string) => - buildFastPreviewDraftUrl({ ...SCOPE, version, path: "/" }); + buildCmsDraftUrl({ ...SCOPE, version, path: "/" }); expect(at("a".repeat(40))).not.toBe(at("b".repeat(40))); }); it("preserves a production origin that carries a trailing slash", () => { const url = new URL( - buildFastPreviewDraftUrl({ + buildCmsDraftUrl({ ...SCOPE, previewServerUrl: "https://fila.vtex.app/", path: "/institucional/historia", @@ -84,7 +82,7 @@ describe("buildFastPreviewDraftUrl", () => { it("keeps path params already filled in", () => { const url = new URL( - buildFastPreviewDraftUrl({ ...SCOPE, path: "/produto/tenis-123/p" }), + buildCmsDraftUrl({ ...SCOPE, path: "/produto/tenis-123/p" }), ); expect(url.pathname).toBe("/produto/tenis-123/p"); }); @@ -96,7 +94,7 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: SANDBOX, previewServerUrl: PROD, - fastPreviewActive: false, + cmsModeActive: false, }), ).toBe(SANDBOX); }); @@ -108,7 +106,7 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: SANDBOX, previewServerUrl: PROD, - fastPreviewActive: true, + cmsModeActive: true, }), ).toBe(PROD); }); @@ -118,19 +116,19 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: null, previewServerUrl: PROD, - fastPreviewActive: true, + cmsModeActive: true, }), ).toBe(PROD); }); it("falls back to the sandbox when Fast Preview is active but has no production URL", () => { - // The `fastPreviewActive` gate already requires a production URL, so this + // The `cmsModeActive` gate already requires a production URL, so this // is defensive: a truthy flag with no URL must not blank the gallery. expect( resolveSectionPreviewBase({ sandboxUrl: SANDBOX, previewServerUrl: null, - fastPreviewActive: true, + cmsModeActive: true, }), ).toBe(SANDBOX); }); @@ -140,7 +138,7 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: null, previewServerUrl: null, - fastPreviewActive: false, + cmsModeActive: false, }), ).toBeNull(); }); @@ -152,7 +150,7 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: null, previewServerUrl: PROD, - fastPreviewActive: false, + cmsModeActive: false, }), ).toBeNull(); }); @@ -162,7 +160,7 @@ describe("resolveSectionPreviewBase", () => { resolveSectionPreviewBase({ sandboxUrl: undefined, previewServerUrl: undefined, - fastPreviewActive: false, + cmsModeActive: false, }), ).toBeNull(); }); diff --git a/apps/web/src/components/sections-editor/section-preview-url.ts b/apps/web/src/components/sections-editor/section-preview-url.ts index 20d6afbf20..ec4fdbfcac 100644 --- a/apps/web/src/components/sections-editor/section-preview-url.ts +++ b/apps/web/src/components/sections-editor/section-preview-url.ts @@ -46,7 +46,7 @@ export function buildGlobalSectionPreviewUrl( * per version, and a new version after a save is what refreshes the frame — * no cache-busting nonce needed. */ -export function buildFastPreviewDraftUrl(input: { +export function buildCmsDraftUrl(input: { /** Preview server origin — the deployment the draft renders against. */ previewServerUrl: string; /** @@ -117,9 +117,9 @@ export function buildSectionPreviewUrl( export function resolveSectionPreviewBase(input: { sandboxUrl: string | null | undefined; previewServerUrl: string | null | undefined; - fastPreviewActive: boolean; + cmsModeActive: boolean; }): string | null { - if (input.fastPreviewActive && input.previewServerUrl) { + if (input.cmsModeActive && input.previewServerUrl) { return input.previewServerUrl; } return input.sandboxUrl ?? null; diff --git a/apps/web/src/components/sections-editor/use-decofile.ts b/apps/web/src/components/sections-editor/use-decofile.ts index db80f1fd2a..90c3618000 100644 --- a/apps/web/src/components/sections-editor/use-decofile.ts +++ b/apps/web/src/components/sections-editor/use-decofile.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; @@ -47,12 +47,12 @@ export function useDecofile( // branch head on GitHub — no dev server, no working tree. The read also // seeds KEYS.decofileDraft ({version, token}) so the preview can build its // `?__draft=` pointer before any save happens. - const fastPreviewActive = resolveFastPreview(vmcp?.metadata).active; + const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; const queryClient = useQueryClient(); return useQuery({ queryKey: KEYS.decofile(key), queryFn: async () => { - if (fastPreviewActive) { + if (cmsModeActive) { return fetchDecofile(queryClient, params!); } const readCommitted = () => @@ -97,7 +97,7 @@ export function useDecofile( // upstream 5xx) to 502 — so a single hiccup would otherwise stick as a // terminal error card. Bounded retries with backoff ARE the recovery. retry: (failureCount, error) => - fastPreviewActive + cmsModeActive ? failureCount < 3 : (error as { status?: number }).status !== 502 && failureCount < 2, retryDelay: (attempt) => diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index f20b9e9831..60f4788a2e 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { KEYS } from "@/lib/query-keys"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -36,12 +36,12 @@ export function useDeleteBlock({ const packagePath = vmcp?.metadata?.runtime?.path ?? null; // Sandbox-less mode: deletes commit through the decofile API and remove every // encoding alias of the key server-side. - const fastPreviewActive = resolveFastPreview(vmcp?.metadata).active; + const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), mutationFn: async ({ blockKey }: { blockKey: string }) => { - if (fastPreviewActive) { + if (cmsModeActive) { const draft = await patchDecofile( { orgSlug, virtualMcpId, branch }, { delete: [blockKey] }, diff --git a/apps/web/src/components/sections-editor/use-live-meta.ts b/apps/web/src/components/sections-editor/use-live-meta.ts index ab281fa93a..367ba2f7df 100644 --- a/apps/web/src/components/sections-editor/use-live-meta.ts +++ b/apps/web/src/components/sections-editor/use-live-meta.ts @@ -5,7 +5,7 @@ import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; import { readCommittedJson } from "./read-committed-file"; import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import type { LiveMeta } from "./resolve-schema"; interface UseLiveMetaParams { @@ -68,7 +68,7 @@ export function useLiveMeta( const virtualMcp = useVirtualMCP(params?.virtualMcpId); const packagePath = virtualMcp?.metadata?.runtime?.path ?? null; const productionUrl = resolvePreviewServerUrl(virtualMcp?.metadata); - const fastPreviewActive = resolveFastPreview(virtualMcp?.metadata).active; + const cmsModeActive = resolveCmsMode(virtualMcp?.metadata).active; return useQuery({ // productionUrl is appended so a settings edit re-fetches; invalidators key // on the (org, vm, branch) prefix, which still matches (variadic key). @@ -127,7 +127,7 @@ export function useLiveMeta( // /live/_meta fetch would stick as a terminal error card, so bounded // retries ARE the recovery there. retry: (failureCount, error) => - fastPreviewActive + cmsModeActive ? failureCount < 3 : (error as { status?: number }).status !== 502 && failureCount < 3, retryDelay: (attempt) => diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index 004d359896..f74bdd227d 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { toast } from "sonner"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -38,7 +38,7 @@ export function useSaveBlock({ // Sandbox-less mode: writes go through the decofile API (a coalesced commit // on the branch) instead of the sandbox working tree. The server owns the // key -> file mapping, so no path construction here. - const fastPreviewActive = resolveFastPreview(vmcp?.metadata).active; + const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), @@ -49,7 +49,7 @@ export function useSaveBlock({ blockKey: string; data: unknown; }) => { - if (fastPreviewActive) { + if (cmsModeActive) { const draft = await patchDecofile( { orgSlug, virtualMcpId, branch }, { set: { [blockKey]: data } }, diff --git a/apps/web/src/components/sections-editor/use-section-preview-base.ts b/apps/web/src/components/sections-editor/use-section-preview-base.ts index b9af7ab5d0..be4062d0c3 100644 --- a/apps/web/src/components/sections-editor/use-section-preview-base.ts +++ b/apps/web/src/components/sections-editor/use-section-preview-base.ts @@ -1,5 +1,5 @@ import { useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { resolveSectionPreviewBase } from "./section-preview-url"; /** @@ -7,7 +7,7 @@ import { resolveSectionPreviewBase } from "./section-preview-url"; * * Fast Preview ON → always the preview server; OFF → the sandbox dev server * (see `resolveSectionPreviewBase`). Fast Preview is gated the same way - * everywhere (`resolveFastPreview`): the switch is on AND a preview server + * everywhere (`resolveCmsMode`): the switch is on AND a preview server * URL is set. * * Returns `null` when neither base is available, so callers withhold the @@ -18,10 +18,10 @@ export function useSectionPreviewBase(input: { sandboxUrl: string | null | undefined; }): string | null { const vmcp = useVirtualMCP(input.virtualMcpId); - const { previewServerUrl, active } = resolveFastPreview(vmcp?.metadata); + const { previewServerUrl, active } = resolveCmsMode(vmcp?.metadata); return resolveSectionPreviewBase({ sandboxUrl: input.sandboxUrl, previewServerUrl, - fastPreviewActive: active, + cmsModeActive: active, }); } diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index c81d77432a..149d7cf1f2 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -43,7 +43,7 @@ import { authClient } from "@/lib/auth-client.ts"; import { resolveGithubAttachment } from "@/lib/github-repo.ts"; import { KEYS } from "@/lib/query-keys"; import { useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { decofileWriteMutationKey } from "../../sections-editor/decofile-api.ts"; import { useChatTask } from "../../chat/index"; import { @@ -95,7 +95,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { attachment.status === "attached" || attachment.status === "public-clone" ? attachment.repo : null; - const { previewServerUrl } = resolveFastPreview(vm?.metadata); + const { previewServerUrl } = resolveCmsMode(vm?.metadata); /** Poll-free on purpose: every call forwards to GitHub; save hooks invalidate this key. */ const statusQuery = useQuery({ diff --git a/apps/web/src/components/thread/github/header-actions.tsx b/apps/web/src/components/thread/github/header-actions.tsx index 7767ef8050..d3f0a99403 100644 --- a/apps/web/src/components/thread/github/header-actions.tsx +++ b/apps/web/src/components/thread/github/header-actions.tsx @@ -1,5 +1,5 @@ import { useMCPClient, useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { useIsMutating, useQuery, useQueryClient } from "@tanstack/react-query"; import { decofileWriteMutationKey } from "@/components/sections-editor/decofile-api"; import { Button } from "@decocms/ui/components/button.tsx"; @@ -130,7 +130,7 @@ export function HeaderActions({ virtualMcpId }: Props) { const queryClient = useQueryClient(); const { data: session } = authClient.useSession(); const vm = useVirtualMCP(virtualMcpId); - const fastPreviewActive = resolveFastPreview(vm?.metadata).active; + const cmsModeActive = resolveCmsMode(vm?.metadata).active; const { currentBranch: branch, setCurrentTaskBranch } = useChatTask(); const chat = useChatStream(); const { openSidePanel } = usePanelActions(); @@ -171,11 +171,11 @@ export function HeaderActions({ virtualMcpId }: Props) { const fpStatusQuery = useQuery({ queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, branch ?? ""), queryFn: () => fetchGitStatus(org.slug, virtualMcpId, branch ?? ""), - enabled: fastPreviewActive && !!branch, + enabled: cmsModeActive && !!branch, staleTime: 15_000, }); const fpStatus = fpStatusQuery.data ?? null; - const branchMeta: BranchMeta = fastPreviewActive + const branchMeta: BranchMeta = cmsModeActive ? fpStatus ? { kind: "ready", @@ -192,7 +192,7 @@ export function HeaderActions({ virtualMcpId }: Props) { // The lifecycle gates the header copy through clone/checkout; sandbox-less // has no boot pipeline, so it reads as permanently running (the port / // htmlSupport fields are dev-server facts nothing on this surface reads). - const lifecycle: LifecycleState = fastPreviewActive + const lifecycle: LifecycleState = cmsModeActive ? { phase: "running", port: 0, htmlSupport: true } : sseLifecycle; @@ -256,7 +256,7 @@ export function HeaderActions({ virtualMcpId }: Props) { // `{allowed: true, ready: false}`, so the side Publish click falls through to // the dialog — which loads the diff once, on open, and gates there. const publishGateEnabled = - !fastPreviewActive && + !cmsModeActive && effectiveBranchMeta.kind === "ready" && Boolean(sandboxRouteBranch) && (effectiveBranchMeta.workingTreeDirty || @@ -387,14 +387,14 @@ export function HeaderActions({ virtualMcpId }: Props) { // non-technical user than a branch-favoured merge). const showSync = (vm?.metadata?.syncButtonEnabled === true || - (fastPreviewActive && + (cmsModeActive && effectiveBranchMeta.kind === "ready" && effectiveBranchMeta.behindBase > 0)) && Boolean(githubRepo) && Boolean(githubHeadBranch); const handleSync = () => { if (isStreaming || !githubHeadBranch) return; - if (!fastPreviewActive) { + if (!cmsModeActive) { void send(tpl.syncBranch({ branch: githubHeadBranch, base: baseBranch })); return; } diff --git a/apps/web/src/hooks/use-layout-state.test.ts b/apps/web/src/hooks/use-layout-state.test.ts index 77c80bdd5b..2cfe785f53 100644 --- a/apps/web/src/hooks/use-layout-state.test.ts +++ b/apps/web/src/hooks/use-layout-state.test.ts @@ -23,6 +23,72 @@ describe("resolveDefaultPanelState", () => { ).toEqual({ sidePanel: "chat", mainOpen: false }); }); + describe("CMS projects", () => { + const cms = { defaultSidePanelKind: "cms" } as const; + + test("a chat default resolves to the CMS panel — there is no chat here", () => { + expect( + resolveDefaultPanelState({ + entityMetadata: { defaultMainView: { type: "chat" } }, + ...absentSearch, + ...cms, + }), + ).toEqual({ sidePanel: "cms", mainOpen: false }); + }); + + test("chatDefaultOpen alongside a non-chat view opens CMS, not chat", () => { + expect( + resolveDefaultPanelState({ + entityMetadata: { + defaultMainView: { type: "preview" }, + chatDefaultOpen: true, + }, + ...absentSearch, + ...cms, + }), + ).toEqual({ sidePanel: "cms", mainOpen: true }); + }); + + test("closing everything falls back to CMS, never to an absent chat", () => { + expect( + resolveDefaultPanelState({ + entityMetadata: { defaultMainView: { type: "settings" } }, + mainParamPresent: true, + mainParamValue: 0, + sidePanelParamPresent: true, + sidePanelParamValue: 0, + ...cms, + }), + ).toEqual({ sidePanel: "cms", mainOpen: false }); + }); + + test("?sidepanel=cms is honoured", () => { + expect( + resolveDefaultPanelState({ + entityMetadata: null, + mainParamPresent: false, + sidePanelParamPresent: true, + sidePanelParamValue: "cms", + ...cms, + }), + ).toEqual({ sidePanel: "cms", mainOpen: false }); + }); + + test("an unknown ?sidepanel degrades to the project default", () => { + expect( + resolveDefaultPanelState({ + entityMetadata: null, + mainParamPresent: false, + sidePanelParamPresent: true, + sidePanelParamValue: "junk" as unknown as Parameters< + typeof resolveDefaultPanelState + >[0]["sidePanelParamValue"], + ...cms, + }), + ).toEqual({ sidePanel: "cms", mainOpen: false }); + }); + }); + test("a Chat default opens Chat and closes Main", () => { expect( resolveDefaultPanelState({ diff --git a/apps/web/src/hooks/use-layout-state.ts b/apps/web/src/hooks/use-layout-state.ts index 93ba334550..ca4433cd96 100644 --- a/apps/web/src/hooks/use-layout-state.ts +++ b/apps/web/src/hooks/use-layout-state.ts @@ -21,7 +21,31 @@ import { useThreadActions, useThreads } from "@/components/chat/store/hooks"; // Types // --------------------------------------------------------------------------- -export type SidePanelKind = "chat"; +/** + * Which editor occupies the side panel. + * + * `"cms"` is the block editor, offered only on projects where CMS mode is + * available (a preview server URL is set — see `sdk/cms-mode.ts`), because that + * is the only configuration where the decofile is reachable over HTTP rather + * than through the sandbox daemon. Everywhere else the side panel is chat. + */ +export type SidePanelKind = "chat" | "cms"; + +const SIDE_PANEL_KINDS: readonly SidePanelKind[] = ["chat", "cms"]; + +/** + * Narrow an untrusted `?sidepanel` value to the union. + * + * The router validates the param, but layout memory and task-switch carry it + * too — and a value that survives one of those paths while failing another is + * how a panel silently disappears. Unknown input degrades to `null` (use the + * caller's default) rather than throwing. + */ +export function parseSidePanelKind(value: unknown): SidePanelKind | null { + return SIDE_PANEL_KINDS.includes(value as SidePanelKind) + ? (value as SidePanelKind) + : null; +} export interface EntityLayoutMetadata { defaultMainView?: { @@ -86,11 +110,17 @@ export function canCloseWorkspacePanel( return panel === "side" ? visibility.sidePanel !== null : visibility.mainOpen; } +/** + * `fallbackKind` is the project's side-panel occupant — `"cms"` where CMS mode + * is available, `"chat"` otherwise. Closing every panel must not resurrect a + * kind the project does not have. + */ function withWorkspaceFallback( visibility: WorkspaceVisibility, + fallbackKind: SidePanelKind, ): WorkspaceVisibility { if (visibility.sidePanel !== null || visibility.mainOpen) return visibility; - return { ...visibility, sidePanel: "chat" }; + return { ...visibility, sidePanel: fallbackKind }; } export function resolveDefaultPanelState(ctx: { @@ -99,23 +129,30 @@ export function resolveDefaultPanelState(ctx: { mainParamValue?: string | 0; sidePanelParamPresent: boolean; sidePanelParamValue?: SidePanelKind | 0; + /** + * Which kind this project's side panel defaults to. Resolved by the caller + * through `resolveCmsMode(...)` so the gate stays in one place. A CMS project + * has no chat, so a chat default resolves to the CMS panel instead. + */ + defaultSidePanelKind?: SidePanelKind; }): WorkspaceVisibility { const mainParamValue = ctx.mainParamValue === 0 ? "0" : ctx.mainParamValue; const defaultView = ctx.entityMetadata?.defaultMainView ?? null; const defaultIsChat = defaultView == null || defaultView.type === "chat"; + const kind: SidePanelKind = ctx.defaultSidePanelKind ?? "chat"; const mainOpen = ctx.mainParamPresent ? mainParamValue !== "0" : !defaultIsChat; const defaultSidePanel: SidePanelKind | null = - defaultIsChat || ctx.entityMetadata?.chatDefaultOpen ? "chat" : null; + defaultIsChat || ctx.entityMetadata?.chatDefaultOpen ? kind : null; const sidePanel = ctx.sidePanelParamPresent ? ctx.sidePanelParamValue === 0 ? null - : (ctx.sidePanelParamValue ?? null) + : (parseSidePanelKind(ctx.sidePanelParamValue) ?? defaultSidePanel) : defaultSidePanel; - return withWorkspaceFallback({ sidePanel, mainOpen }); + return withWorkspaceFallback({ sidePanel, mainOpen }, kind); } export function resolveWorkspacePanelAction( @@ -172,12 +209,14 @@ export type MobileWorkspaceSurface = SidePanelKind | "main"; export function resolveMobileSurface(ctx: { visibility: WorkspaceVisibility; sidePanelParamPresent: boolean; + /** The project's side-panel occupant; see `resolveDefaultPanelState`. */ + defaultSidePanelKind?: SidePanelKind; }): MobileWorkspaceSurface { const { sidePanel, mainOpen } = ctx.visibility; if (sidePanel !== null && (ctx.sidePanelParamPresent || !mainOpen)) { return sidePanel; } - return mainOpen ? "main" : "chat"; + return mainOpen ? "main" : (ctx.defaultSidePanelKind ?? "chat"); } export function mobileSurfaceSearch( @@ -202,6 +241,11 @@ export interface WorkspaceLayoutStateRouteCtx { virtualMcpId: string; orgSlug: string; isAgentRoute: boolean; + /** + * The project's side-panel occupant — `"cms"` where CMS mode is available. + * Resolved by the caller so the gate is read in one place. + */ + defaultSidePanelKind?: SidePanelKind; } export function useWorkspaceLayoutState( @@ -217,7 +261,8 @@ export function useWorkspaceLayoutState( const { create } = useThreadActions(); const { threads } = useThreads(); - const { virtualMcpId, orgSlug, isAgentRoute } = routeCtx; + const { virtualMcpId, orgSlug, isAgentRoute, defaultSidePanelKind } = + routeCtx; const mainParam = search.main === 0 ? "0" : search.main; const { sidePanel, mainOpen } = resolveDefaultPanelState({ @@ -226,6 +271,7 @@ export function useWorkspaceLayoutState( mainParamValue: mainParam, sidePanelParamPresent: search.sidepanel !== undefined, sidePanelParamValue: search.sidepanel, + defaultSidePanelKind, }); const visibility = { sidePanel, mainOpen }; diff --git a/apps/web/src/i18n/en/agent-shell-layout.ts b/apps/web/src/i18n/en/agent-shell-layout.ts index 65a1b20b37..edb6a958d2 100644 --- a/apps/web/src/i18n/en/agent-shell-layout.ts +++ b/apps/web/src/i18n/en/agent-shell-layout.ts @@ -17,6 +17,7 @@ export const agentShellLayout = { "agentShellLayout.agentShellLayout.taskUnavailable": "Task unavailable", "agentShellLayout.libraryToggle.library": "Library", "agentShellLayout.tasksToggle.tasks": "Tasks", + "agentShellLayout.toggleButtons.cms": "CMS", "agentShellLayout.toggleButtons.chat": "Chat", "agentShellLayout.toolbar.backToHome": "Back to home", "agentShellLayout.toolbar.logo": "Logo", diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index b01ddcee51..77aed4ee2e 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -236,8 +236,8 @@ export const chat = { "chat.input.planMode": "Plan mode", "chat.input.codingAgentRequiresDesktop": "Continue this coding-agent chat in the Studio desktop app.", - "chat.input.fastPreviewComingSoon": - "Chat isn't available on Fast Preview projects yet — coming soon. Use the CMS to edit content.", + "chat.input.cmsModeNoChat": + "Chat isn't available in CMS mode — use the CMS panel to edit content.", "chat.input.readOnlyOthersChat": "Read only - you're viewing someone else's chat", "chat.input.readOnlyOthersChatNamed": diff --git a/apps/web/src/i18n/en/sandbox.ts b/apps/web/src/i18n/en/sandbox.ts index f5611d2dbd..3c30fd86d5 100644 --- a/apps/web/src/i18n/en/sandbox.ts +++ b/apps/web/src/i18n/en/sandbox.ts @@ -522,11 +522,11 @@ export const sandbox = { "sandbox.cmsSettings.preview.title": "Preview", "sandbox.cmsSettings.preview.description": "See your changes before they go live.", - "sandbox.cmsSettings.fastPreview.label": "Fast Preview", - "sandbox.cmsSettings.fastPreview.description": - "Preview changes on your preview server instead of the sandbox.", - "sandbox.cmsSettings.fastPreview.needsPreviewServerUrl": - "Set a preview server above to enable Fast Preview.", + "sandbox.cmsSettings.cmsMode.label": "CMS mode", + "sandbox.cmsSettings.cmsMode.description": + "Edit content against your preview server — no dev environment needed.", + "sandbox.cmsSettings.cmsMode.needsPreviewServerUrl": + "Set a preview server above to enable CMS mode.", "sandbox.cmsSettings.editing.title": "Editing", "sandbox.cmsSettings.editing.description": "Customize the content-editing experience in the blocks form.", diff --git a/apps/web/src/i18n/pt-br/agent-shell-layout.ts b/apps/web/src/i18n/pt-br/agent-shell-layout.ts index 136f019d1b..436d470cdd 100644 --- a/apps/web/src/i18n/pt-br/agent-shell-layout.ts +++ b/apps/web/src/i18n/pt-br/agent-shell-layout.ts @@ -20,6 +20,7 @@ export const agentShellLayout = { "agentShellLayout.agentShellLayout.taskUnavailable": "Tarefa indisponível", "agentShellLayout.libraryToggle.library": "Biblioteca", "agentShellLayout.tasksToggle.tasks": "Tarefas", + "agentShellLayout.toggleButtons.cms": "CMS", "agentShellLayout.toggleButtons.chat": "Chat", "agentShellLayout.toolbar.backToHome": "Voltar para home", "agentShellLayout.toolbar.logo": "Logo", diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index 6851eacf33..96507012a2 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -243,8 +243,8 @@ export const chat = { "chat.input.planMode": "Modo de planejamento", "chat.input.codingAgentRequiresDesktop": "Continue este chat do agente de código no aplicativo Studio para desktop.", - "chat.input.fastPreviewComingSoon": - "O chat ainda não está disponível em projetos Fast Preview — em breve. Use o CMS para editar o conteúdo.", + "chat.input.cmsModeNoChat": + "O chat não está disponível no modo CMS — use o painel CMS para editar o conteúdo.", "chat.input.readOnlyOthersChat": "Apenas leitura - você está visualizando um chat de outra pessoa", "chat.input.readOnlyOthersChatNamed": diff --git a/apps/web/src/i18n/pt-br/sandbox.ts b/apps/web/src/i18n/pt-br/sandbox.ts index 8661c14815..88ca29b2f2 100644 --- a/apps/web/src/i18n/pt-br/sandbox.ts +++ b/apps/web/src/i18n/pt-br/sandbox.ts @@ -545,11 +545,11 @@ export const sandbox = { "sandbox.cmsSettings.preview.title": "Preview", "sandbox.cmsSettings.preview.description": "Veja suas alterações antes de publicá-las.", - "sandbox.cmsSettings.fastPreview.label": "Preview Rápido", - "sandbox.cmsSettings.fastPreview.description": - "Pré-visualize alterações no seu servidor de preview em vez do sandbox.", - "sandbox.cmsSettings.fastPreview.needsPreviewServerUrl": - "Defina um servidor de preview acima para ativar o Preview Rápido.", + "sandbox.cmsSettings.cmsMode.label": "Modo CMS", + "sandbox.cmsSettings.cmsMode.description": + "Edite conteúdo no seu servidor de preview — sem precisar de ambiente de desenvolvimento.", + "sandbox.cmsSettings.cmsMode.needsPreviewServerUrl": + "Defina um servidor de preview acima para ativar o Modo CMS.", "sandbox.cmsSettings.editing.title": "Edição", "sandbox.cmsSettings.editing.description": "Personalize a experiência de edição de conteúdo no formulário de blocos.", diff --git a/apps/web/src/layouts/agent-shell-layout/index.tsx b/apps/web/src/layouts/agent-shell-layout/index.tsx index 7ac7ea5fc2..ecc984e3ec 100644 --- a/apps/web/src/layouts/agent-shell-layout/index.tsx +++ b/apps/web/src/layouts/agent-shell-layout/index.tsx @@ -75,6 +75,7 @@ import { ShellRouteLoading } from "@/layouts/shell-route-loading"; import { OrgFilePreviewMount } from "./org-file-preview"; import { OrgFileOpenProvider } from "@/components/chat/org-file-open-context"; import { BlocksPreviewWorkspaceProvider } from "@/components/sandbox/blocks/blocks-preview-workspace-context"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { SidePanel } from "./side-panel"; import { useIsDesktopApp } from "@/hooks/use-is-desktop-app"; import { useAgentRuntimeAdapter } from "@/lib/desktop/agent-runtime-slot"; @@ -505,6 +506,9 @@ function AgentInsetProvider() { virtualMcpId, orgSlug, isAgentRoute: true, + defaultSidePanelKind: resolveCmsMode(entity?.metadata).active + ? "cms" + : "chat", }); const onNewTask = useRef<(() => void) | null>(null); diff --git a/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx b/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx index 1a778b1495..d5d1187499 100644 --- a/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx +++ b/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx @@ -1,7 +1,8 @@ -import { MessageCircle01 } from "@untitledui/icons"; +import { MessageCircle01, PuzzlePiece01 } from "@untitledui/icons"; import { HeaderTabButton } from "@/layouts/main-panel-tabs/header-tab-button"; import { track } from "@/lib/posthog-client"; import { useT } from "@/i18n/use-t"; +import { TOUR_ANCHORS } from "@/components/cms-tour/anchors"; import type { SidePanelKind } from "@/hooks/use-layout-state"; export interface ChatToggleProps { @@ -45,3 +46,37 @@ export function ChatToggle({ /> ); } + +/** + * CMS toggle — the side-panel occupant on projects where CMS mode is available. + * A sibling of {@link ChatToggle} rather than a branch inside it, so neither + * surface has to reason about the other's project type. + * + * It carries `TOUR_ANCHORS.edit`, which the CMS tour uses as its readiness gate + * — the anchor moved here from the preview toolbar's Edit-content button. + */ +export function CmsToggle({ + sidePanel, + toggleSidePanel, + disableActiveSidePanelToggle = false, +}: ChatToggleProps) { + const t = useT(); + return ( + { + track("agent_toolbar_toggled", { + button: "cms", + next_state: sidePanel === "cms" ? "closed" : "open", + }); + toggleSidePanel("cms"); + }} + /> + ); +} diff --git a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx index cd301dcbf5..c79395a088 100644 --- a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx +++ b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx @@ -16,7 +16,7 @@ import { type ReactNode, } from "react"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { ResizableHandle, ResizablePanel, @@ -43,9 +43,8 @@ import { } from "@/components/header/shell-breadcrumb"; import { useSidebar } from "@decocms/ui/components/sidebar.tsx"; import { SidePanel } from "./side-panel"; -import { ChatToggle } from "./toggle-buttons"; -import { MessageCircle01 } from "@untitledui/icons"; -import { useT } from "@/i18n/use-t"; +import { ChatToggle, CmsToggle } from "./toggle-buttons"; +import { BlocksPanel } from "@/components/sandbox/blocks/blocks-panel"; import { MainPanelHeaderEndSlot, MainPanelHeaderProvider, @@ -53,23 +52,6 @@ import { PanelHeader, } from "./panel-header"; -/** - * Chat panel body for Fast Preview projects: the surface exists (toggle, - * panel, layout all behave normally) but sending is not possible yet — a - * run would dispatch to a sandbox runner this mode never provisions. - */ -function FastPreviewChatNotice() { - const t = useT(); - return ( -
- -

- {t("chat.input.fastPreviewComingSoon")} -

-
- ); -} - const SIDE_PANEL_ID = "workspace-side-panel"; const MAIN_PANEL_ID = "workspace-main-panel"; @@ -139,12 +121,8 @@ export function WorkspacePanelGroup({ toggleSidePanel, chatContent, }: WorkspacePanelGroupProps) { - // Fast Preview projects are sandbox-less: the chat toggle and panel behave - // normally, but the panel's CONTENT is held behind a notice — a thread run - // would dispatch to a sandbox runner that never exists in this mode (in the - // native app the panel would greet the user with a broken coding-agent - // picker). - const fastPreviewActive = resolveFastPreview(entity.metadata).active; + // Sandbox-less: the side panel hosts the block editor, not an inert chat. + const cmsModeActive = resolveCmsMode(entity.metadata).active; const [sidePanelWidth, setSidePanelWidth] = useSidePanelWidth(); const panelGroupRef = useRef(null); const visibility = { sidePanel, mainOpen }; @@ -199,11 +177,19 @@ export function WorkspacePanelGroup({ const chatHeader = ( {agentCrumb} - + {cmsModeActive ? ( + + ) : ( + + )} {mainControlsInChat && ( {!chatOpen && agentCrumb} - {!chatOpen && ( - - )} + {!chatOpen && + (cmsModeActive ? ( + + ) : ( + + ))} {chatOpen && - (fastPreviewActive ? ( - + (cmsModeActive ? ( + ) : ( ))} diff --git a/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.test.ts b/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.test.ts index c008a4794c..e770633a17 100644 --- a/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.test.ts +++ b/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.test.ts @@ -227,7 +227,7 @@ describe("resolveBlocksTabState", () => { }); }); -describe("sandbox-less Fast Preview (fastPreviewActive)", () => { +describe("sandbox-less Fast Preview (cmsModeActive)", () => { test("ignores the lifecycle phase entirely — no sandbox will ever boot", () => { // Without the flag, "idle" classifies as booting and spins forever. expect( @@ -235,7 +235,7 @@ describe("sandbox-less Fast Preview (fastPreviewActive)", () => { input({ lifecyclePhase: "idle", hasEditableContent: true, - fastPreviewActive: true, + cmsModeActive: true, }), ), ).toEqual({ kind: "content" }); @@ -245,7 +245,7 @@ describe("sandbox-less Fast Preview (fastPreviewActive)", () => { input({ lifecyclePhase: "clone-failed", hasEditableContent: true, - fastPreviewActive: true, + cmsModeActive: true, }), ), ).toEqual({ kind: "content" }); @@ -257,7 +257,7 @@ describe("sandbox-less Fast Preview (fastPreviewActive)", () => { input({ lifecyclePhase: "idle", decofile: { status: "error", hasData: false, errorStatus: 502 }, - fastPreviewActive: true, + cmsModeActive: true, }), ), ).toEqual({ kind: "error", source: "data" }); @@ -269,13 +269,13 @@ describe("sandbox-less Fast Preview (fastPreviewActive)", () => { input({ lifecyclePhase: "idle", decofile: { status: "pending", hasData: false }, - fastPreviewActive: true, + cmsModeActive: true, }), ), ).toEqual({ kind: "loading" }); expect( resolveBlocksTabState( - input({ lifecyclePhase: "idle", fastPreviewActive: true }), + input({ lifecyclePhase: "idle", cmsModeActive: true }), ), ).toEqual({ kind: "empty" }); }); diff --git a/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.ts b/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.ts index 7cfb892323..d9f54169f7 100644 --- a/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.ts +++ b/apps/web/src/layouts/main-panel-tabs/blocks-tab-state.ts @@ -41,7 +41,7 @@ export interface BlocksTabStateInput { /** Sandbox-less Fast Preview: content comes from the decofile API (GitHub), * not a sandbox — the lifecycle phase stays "idle" forever and must not * gate the panel. Data readiness alone decides. */ - fastPreviewActive?: boolean; + cmsModeActive?: boolean; } export type BlocksTabState = @@ -89,7 +89,7 @@ function classifyPhase(phase: LifecycleState["phase"]): PhaseClass { export function resolveBlocksTabState( input: BlocksTabStateInput, ): BlocksTabState { - if (input.fastPreviewActive) { + if (input.cmsModeActive) { // No sandbox: a failed decofile/meta read is immediately real (there is // no lifecycle transition coming to re-invalidate it). const failed = diff --git a/apps/web/src/layouts/resolve-task-switch-search.ts b/apps/web/src/layouts/resolve-task-switch-search.ts index 9ee9432526..9ef90ac8ef 100644 --- a/apps/web/src/layouts/resolve-task-switch-search.ts +++ b/apps/web/src/layouts/resolve-task-switch-search.ts @@ -20,6 +20,7 @@ import { isPerThreadTab } from "@/layouts/main-panel-tabs/tab-id"; import type { ThreadLayout } from "@/lib/thread-layout-memory"; +import type { SidePanelKind } from "@/hooks/use-layout-state"; export interface ResolveTaskSwitchInput { /** The current (source) thread's search params. */ @@ -57,7 +58,7 @@ export function resolveTaskSwitchSearch( // Only pin a side-panel value when the target thread remembered one; leaving // it undefined omits `sidepanel` from the URL so the agent-configured default // (resolveDefaultPanelState) applies instead of forcing chat open. - let sidepanel: "chat" | 0 | undefined; + let sidepanel: SidePanelKind | 0 | undefined; if (opts?.main) { // Explicit intent wins outright — ignore saved/carried layout. diff --git a/apps/web/src/layouts/shell-layout.tsx b/apps/web/src/layouts/shell-layout.tsx index 88a35c388e..98f144611e 100644 --- a/apps/web/src/layouts/shell-layout.tsx +++ b/apps/web/src/layouts/shell-layout.tsx @@ -10,6 +10,7 @@ import RequiredAuthLayout from "@/layouts/required-auth-layout"; import { authClient } from "@/lib/auth-client"; import { AUTOSEND_QUERY_VALUE } from "@/lib/autosend"; import { LOCALSTORAGE_KEYS } from "@/lib/localstorage-keys"; +import type { SidePanelKind } from "@/hooks/use-layout-state"; import { readCachedOrg, writeCachedOrg } from "@/lib/query-persist"; import { PostHogGroupSync } from "@/providers/posthog-group-sync"; import { @@ -94,7 +95,7 @@ export function usePanelActions() { const search = useSearch({ strict: false }) as { virtualmcpid?: string; main?: string | 0; - sidepanel?: "chat" | 0; + sidepanel?: SidePanelKind | 0; }; const orgSlug = params.org ?? ""; const currentTaskId = params.taskId ?? ""; @@ -116,7 +117,7 @@ export function usePanelActions() { replace = true, ) => navWith(currentTaskId, searchFn, replace); - const openSidePanel = (sidePanel: "chat") => + const openSidePanel = (sidePanel: SidePanelKind) => nav((prev) => ({ ...prev, sidepanel: sidePanel })); const setTaskId = ( diff --git a/apps/web/src/lib/thread-layout-memory.test.ts b/apps/web/src/lib/thread-layout-memory.test.ts index ad72828389..6b8ffb1b1f 100644 --- a/apps/web/src/lib/thread-layout-memory.test.ts +++ b/apps/web/src/lib/thread-layout-memory.test.ts @@ -16,6 +16,15 @@ describe("sanitizeThreadLayout", () => { }); }); + test("keeps the cms side panel — it must survive a thread round-trip", () => { + expect(sanitizeThreadLayout({ main: "preview", sidepanel: "cms" })).toEqual( + { + main: "preview", + sidepanel: "cms", + }, + ); + }); + test("drops absent fields (meaning: use the default)", () => { expect(sanitizeThreadLayout({})).toEqual({}); expect(sanitizeThreadLayout({ main: "git" })).toEqual({ main: "git" }); diff --git a/apps/web/src/lib/thread-layout-memory.ts b/apps/web/src/lib/thread-layout-memory.ts index 473c8038c8..26bf5f59da 100644 --- a/apps/web/src/lib/thread-layout-memory.ts +++ b/apps/web/src/lib/thread-layout-memory.ts @@ -15,6 +15,11 @@ * write is wrapped. A read failure means "no memory", never a crash. */ +import { + parseSidePanelKind, + type SidePanelKind, +} from "@/hooks/use-layout-state"; + const STORAGE_KEY = "studio:thread-layout:v1"; /** LRU cap. Bounds growth within a session; oldest threads evict first. */ @@ -23,8 +28,8 @@ const MAX_THREADS = 50; export interface ThreadLayout { /** `?main` value: a tab id, or `0` for the closed main panel. */ main?: string | 0; - /** `?sidepanel` value: `"chat"` open, or `0` closed. */ - sidepanel?: "chat" | 0; + /** `?sidepanel` value: a {@link SidePanelKind} when open, or `0` closed. */ + sidepanel?: SidePanelKind | 0; } /** Most-recent entry last, so `.shift()` evicts the least-recently-saved. */ @@ -40,8 +45,11 @@ export function sanitizeThreadLayout(layout: ThreadLayout): ThreadLayout { if (layout.main === 0 || typeof layout.main === "string") { clean.main = layout.main; } - if (layout.sidepanel === 0 || layout.sidepanel === "chat") { - clean.sidepanel = layout.sidepanel; + if (layout.sidepanel === 0) { + clean.sidepanel = 0; + } else { + const kind = parseSidePanelKind(layout.sidepanel); + if (kind) clean.sidepanel = kind; } return clean; } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index e0876ed521..adb1314788 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -295,7 +295,9 @@ const agentShellLayout = createRoute({ const unifiedChatSearchSchema = z.object({ virtualmcpid: z.string().optional(), tab: z.string().optional(), - sidepanel: z.union([z.literal("chat"), z.literal(0)]).optional(), + sidepanel: z + .union([z.literal("chat"), z.literal("cms"), z.literal(0)]) + .optional(), main: z.union([z.string(), z.literal(0)]).optional(), /** Open the Library file-preview overlay over the chat (browse-grammar path * "/"). Set by clickable org-file refs in agent messages. */ diff --git a/apps/web/src/sdk/cms-mode.ts b/apps/web/src/sdk/cms-mode.ts new file mode 100644 index 0000000000..b5edbb7445 --- /dev/null +++ b/apps/web/src/sdk/cms-mode.ts @@ -0,0 +1,14 @@ +/** + * Web-side re-export of the shared CMS-mode gate. + * + * The gate itself lives in `@decocms/shared/cms-mode` so the API reads the same + * rule (and the same legacy-key fallback) as the UI. Keeping this module means + * web callers import from one place and the shared package stays the only + * definition. + */ + +export { + resolveCmsMode, + type CmsModeGate, + type CmsModeMetadata, +} from "@decocms/shared/cms-mode"; diff --git a/apps/web/src/sdk/fast-preview.ts b/apps/web/src/sdk/fast-preview.ts deleted file mode 100644 index 412fbb7603..0000000000 --- a/apps/web/src/sdk/fast-preview.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; - -/** - * The Fast Preview gate, in ONE place. - * - * Fast Preview is on when the CMS switch (`metadata.fastPreview`) is set AND a - * valid preview server URL is persisted (`metadata.previewServerUrl`, or the - * legacy `productionUrl` key) — a bare flag with no URL is inert (there is - * nothing to render against). Pure so it serves every source of the vmcp - * metadata (the `useVirtualMCP` query, the ambient inset entity) without a - * hook, and so the gate can't drift across the surfaces that read it. - */ -export function resolveFastPreview( - metadata: - | { - previewServerUrl?: string | null; - productionUrl?: string | null; - fastPreview?: boolean | null; - } - | null - | undefined, -): { previewServerUrl: string | null; active: boolean } { - const previewServerUrl = resolvePreviewServerUrl(metadata); - return { - previewServerUrl, - active: !!previewServerUrl && metadata?.fastPreview === true, - }; -} diff --git a/apps/web/src/views/virtual-mcp/header-info.tsx b/apps/web/src/views/virtual-mcp/header-info.tsx index d29f68c94b..1038b7b3c9 100644 --- a/apps/web/src/views/virtual-mcp/header-info.tsx +++ b/apps/web/src/views/virtual-mcp/header-info.tsx @@ -1,6 +1,6 @@ import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { agentShowsGithubHeaderActions } from "@/lib/agent-capabilities"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { CmsHeaderActions } from "../../components/thread/github/cms-header-actions.tsx"; import { HeaderActions } from "../../components/thread/github/header-actions.tsx"; import { DevAgentControl } from "../../components/dev-agent/dev-agent-control.tsx"; @@ -19,14 +19,14 @@ export function VirtualMcpHeaderInfo({ }: { virtualMcp: VirtualMCPEntity; }) { - const fastPreviewActive = resolveFastPreview(virtualMcp.metadata).active; + const cmsModeActive = resolveCmsMode(virtualMcp.metadata).active; return (
{agentShowsGithubHeaderActions(virtualMcp) ? ( - fastPreviewActive ? ( + cmsModeActive ? ( ) : ( diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index 17044da508..13f903bc08 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -83,7 +83,7 @@ import { RuntimeFields } from "@/components/sandbox/runtime-card/runtime-fields" import { PreviewServerUrlField } from "@/components/sandbox/runtime-card/preview-server-url-field"; import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; import { FieldDescriptionTooltipsField } from "@/components/sandbox/runtime-card/field-description-tooltips-field"; -import { FastPreviewField } from "@/components/sandbox/runtime-card/fast-preview-field"; +import { CmsModeField } from "@/components/sandbox/runtime-card/cms-mode-field"; import { PublishPolicyField } from "./publish-policy-field"; type DialogState = { @@ -1095,7 +1095,7 @@ function VirtualMcpDetailViewWithData({

- diff --git a/packages/e2e/tests/decofile-api.spec.ts b/packages/e2e/tests/decofile-api.spec.ts index 1e05222508..33001110c6 100644 --- a/packages/e2e/tests/decofile-api.spec.ts +++ b/packages/e2e/tests/decofile-api.spec.ts @@ -262,7 +262,7 @@ test.describe("decofile API", () => { const gated = await ctx.get(`/api/${org}/decofile/${bare.item.id}/main`); expect(gated.status()).toBe(404); expect(await gated.json()).toEqual({ - error: "Fast Preview is not enabled for this project", + error: "CMS mode is not enabled for this project", }); // fastPreview flag alone is inert without a valid production URL. @@ -309,7 +309,31 @@ test.describe("decofile API", () => { ); expect(flagOnlyRes.status()).toBe(404); expect(await flagOnlyRes.json()).toEqual({ - error: "Fast Preview is not enabled for this project", + error: "CMS mode is not enabled for this project", + }); + + // Current `cmsMode` key alone opens the gate; every other case seeds legacy. + const newKey = await callSelfMcpTool<{ item: { id: string } }>( + ctx, + org, + "COLLECTION_VIRTUAL_MCP_CREATE", + { + data: { + title: `cms-mode-key ${Date.now()}`, + metadata: { + cmsMode: true, + previewServerUrl: "https://cms-mode.example.com", + }, + connections: [], + }, + }, + ); + const newKeyRes = await ctx.get( + `/api/${org}/decofile/${newKey.item.id}/main`, + ); + expect(newKeyRes.status()).toBe(404); + expect(await newKeyRes.json()).toEqual({ + error: "Project has no GitHub repository", }); } finally { await ctx.dispose(); diff --git a/packages/shared/src/cms-mode.ts b/packages/shared/src/cms-mode.ts new file mode 100644 index 0000000000..5c688b1781 --- /dev/null +++ b/packages/shared/src/cms-mode.ts @@ -0,0 +1,48 @@ +/** + * The CMS-mode gate, in ONE place — shared by the web app and the API. + * + * CMS mode (formerly "Fast Preview") is the sandbox-less editing surface: the + * decofile is read and written over HTTP against a preview server instead of + * through the sandbox daemon, so no pod is needed. That is only possible when a + * preview server URL is persisted, which is why the URL is part of the gate + * rather than a separate check — a bare flag with no URL has nothing to render + * against. + * + * `metadata.cmsMode` is the current key; `metadata.fastPreview` is the legacy + * one and is still read. Writers keep writing `fastPreview` until every reader + * ships — the API gates the decofile route on it (`decofile.ts`) and the + * sandbox proxy mints its sandbox-less claim from it, so a premature switch + * would 404 the CMS for any project toggled after the change. + */ + +import { resolvePreviewServerUrl } from "./deco-site-production-url.ts"; + +export interface CmsModeMetadata { + previewServerUrl?: string | null; + productionUrl?: string | null; + cmsMode?: boolean | null; + /** Legacy key for {@link CmsModeMetadata.cmsMode}. */ + fastPreview?: boolean | null; +} + +export interface CmsModeGate { + previewServerUrl: string | null; + active: boolean; +} + +/** True when either the current or the legacy flag is set. */ +function readCmsModeFlag( + metadata: CmsModeMetadata | null | undefined, +): boolean { + return metadata?.cmsMode === true || metadata?.fastPreview === true; +} + +export function resolveCmsMode( + metadata: CmsModeMetadata | null | undefined, +): CmsModeGate { + const previewServerUrl = resolvePreviewServerUrl(metadata); + return { + previewServerUrl, + active: !!previewServerUrl && readCmsModeFlag(metadata), + }; +} diff --git a/packages/shared/src/sdk/types/virtual-mcp.ts b/packages/shared/src/sdk/types/virtual-mcp.ts index 957fca6f5d..76b6688ed3 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -620,12 +620,12 @@ const publishPolicyMetadataField = PublishPolicySchema.nullable() * static single-component render — and it keeps the canvas for as long as * Fast Preview is on. */ -const fastPreviewMetadataField = z +const cmsModeMetadataField = z .boolean() .nullable() .optional() .describe( - "Enable Fast Preview (sandbox-less): render the draft instantly on the preview server's own page via a ?__draft pointer, with reads/writes served by the decofile API against GitHub. Requires previewServerUrl (or legacy productionUrl) to be set to take effect.", + "Enable CMS mode (sandbox-less): render the draft instantly on the preview server's own page via a ?__draft pointer, with reads/writes served by the decofile API against GitHub. Requires previewServerUrl (or legacy productionUrl) to be set to take effect.", ); /** @@ -745,7 +745,7 @@ export const VirtualMCPEntitySchema = z.object({ .describe( "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), - fastPreview: fastPreviewMetadataField, + fastPreview: cmsModeMetadataField, syncButtonEnabled: syncButtonEnabledMetadataField, }) .loose() @@ -861,7 +861,7 @@ export const VirtualMCPCreateDataSchema = z.object({ .describe( "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), - fastPreview: fastPreviewMetadataField, + fastPreview: cmsModeMetadataField, syncButtonEnabled: syncButtonEnabledMetadataField, }) .loose() @@ -958,7 +958,7 @@ export const VirtualMCPUpdateDataSchema = z.object({ .describe( "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), - fastPreview: fastPreviewMetadataField, + fastPreview: cmsModeMetadataField, syncButtonEnabled: syncButtonEnabledMetadataField, }) .loose() From a31cbe26fd0f966f01c5cefaba2022536ed80f9d Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 09:20:06 -0300 Subject: [PATCH 02/19] fix(cms-mode): hide the new-chat action in CMS mode CMS mode has no chat to start, so the relocated new-chat button in the panel header was offering a dead action. The sidebar still exposes it when expanded. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/layouts/agent-shell-layout/workspace-panel-group.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx index c79395a088..578a9a1c3b 100644 --- a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx +++ b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx @@ -152,7 +152,9 @@ export function WorkspacePanelGroup({ const { state: sidebarState } = useSidebar(); const sidebarCollapsed = sidebarState === "collapsed"; const agentCrumb = sidebarCollapsed ? : null; - const newChatCrumb = sidebarCollapsed ? : null; + // No chat to start in CMS mode; the sidebar still offers it when expanded. + const newChatCrumb = + sidebarCollapsed && !cmsModeActive ? : null; const publishActions = ; From 7e159b93beeeba24e55a5fa460f675430d40e783 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 09:53:10 -0300 Subject: [PATCH 03/19] fix(cms-mode): gate the terminal on capability, not on the toggle's view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console rendered in CMS mode. Three places decided its visibility and none agreed: the provider derived `visible` from a preference and a per-VM localStorage override, `preview.tsx` hid the ⋯ menu item when CMS mode was on, and MainPanelWithDrawer gated the drawer on `hasClonableSource` — true for a CMS project, which has a repo. So a default-on preference (or a stale per-VM override) kept the drawer mounted, while the only control that could dismiss it was hidden. Move the condition to where the state lives: the provider takes `available` and forces `visible` false when there is no daemon to attach to, so an override cannot resurrect it. Consumers read `available` instead of re-deriving the gate — which removes the CMS branch from preview.tsx rather than adding a fourth one. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/sandbox/preview/preview.tsx | 2 +- .../main-panel-tabs/main-panel-with-drawer.tsx | 8 +++++++- .../main-panel-tabs/terminal-visibility.tsx | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx index 733dacee00..40ca55d041 100644 --- a/apps/web/src/components/sandbox/preview/preview.tsx +++ b/apps/web/src/components/sandbox/preview/preview.tsx @@ -1593,7 +1593,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // up), while copy / SEO items are gated on the preview being live. // Fast Preview is sandbox-less — there is no terminal to show, so the // toggle is withheld entirely rather than opening an empty drawer. - const terminalToggle = cmsModeEnabled ? null : terminal; + const terminalToggle = terminal?.available ? terminal : null; const moreMenu = showPreviewToolbar || terminalToggle ? (
diff --git a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx index 0db8586ce6..f442620280 100644 --- a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx +++ b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx @@ -8,6 +8,7 @@ import { useSearch } from "@tanstack/react-router"; import { useChatTask } from "@/components/chat/chat-context"; import { useInsetContext } from "@/layouts/agent-shell-layout"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { MainPanelContent } from "@/layouts/main-panel-tabs"; import { OVERLAY_TABS } from "./tab-id"; import { PreviewDrawerHost } from "./preview-drawer-host"; @@ -42,9 +43,14 @@ export function MainPanelWithDrawer({ agentHasClonableSource(activeTask?.metadata); const showDrawer = hasClonableSource && !(typeof main === "string" && OVERLAY_TABS.has(main)); + // CMS mode is sandbox-less — there is no daemon for a terminal to attach to. + const terminalAvailable = !resolveCmsMode(inset?.entity?.metadata).active; return ( - +
diff --git a/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx b/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx index a47c0fad35..1fb061d13a 100644 --- a/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx +++ b/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx @@ -33,6 +33,13 @@ function writePersisted(id: string, visible: boolean): void { } interface TerminalVisibilityCtx { + /** + * Whether this project can have a terminal at all. False when there is no + * daemon behind it (CMS mode). Consumers gate their CONTROLS on this; they + * must not re-derive the condition, or the control and the surface it + * toggles can disagree. + */ + available: boolean; visible: boolean; setVisible: (visible: boolean) => void; } @@ -43,9 +50,12 @@ const TerminalVisibilityContext = createContext( export function TerminalVisibilityProvider({ virtualMcpId, + available = true, children, }: { virtualMcpId: string | null; + /** False when the project has no daemon to attach to (CMS mode). */ + available?: boolean; children: ReactNode; }) { const storageKey = virtualMcpId ?? "__no-vmcp__"; @@ -64,6 +74,7 @@ export function TerminalVisibilityProvider({ } const setVisible = (next: boolean) => { + if (!available) return; setOverrideState(next); writePersisted(storageKey, next); }; @@ -71,7 +82,10 @@ export function TerminalVisibilityProvider({ return ( From a91227bf266a902ea0db3d3b9aeebcf989b1077e Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 10:19:30 -0300 Subject: [PATCH 04/19] refactor(preview): drop the terminal-visibility flag; the drawer owns collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal visibility was two stacked persisted booleans: "does the drawer exist" (a preference plus a per-VM override, behind the ⋯ menu) and "is it expanded" (PreviewDrawerHost's own open/height, per VM). The first layer only existed to hide a surface the second layer could already collapse — and hiding it removed the very control that dismissed it. Now the drawer mounts whenever the project can have one and sits collapsed to its toolbar until expanded. Collapsed it renders the toolbar only: no xterm instances, and no new connections, since the lifecycle and events contexts are already mounted at the shell. Removes TerminalVisibilityProvider, the terminalVisibleByDefault preference and its Settings row, the per-VM localStorage override with its parser and tests, the ⋯ Show/Hide item, and four i18n keys in both dictionaries. Net -223 lines, and the class of bug this replaces — a hidden control stranding a visible surface — is now unrepresentable. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/sandbox/preview/preview.tsx | 199 ++++++++---------- apps/web/src/hooks/use-preferences.ts | 7 - apps/web/src/i18n/en/sandbox.ts | 2 - apps/web/src/i18n/en/settings.ts | 3 - apps/web/src/i18n/pt-br/sandbox.ts | 2 - apps/web/src/i18n/pt-br/settings.ts | 4 - .../main-panel-tabs/drawer-storage.test.ts | 29 +-- .../layouts/main-panel-tabs/drawer-storage.ts | 15 -- .../main-panel-with-drawer.tsx | 49 ++--- .../main-panel-tabs/terminal-visibility.tsx | 100 --------- .../views/settings/profile-preferences.tsx | 27 --- 11 files changed, 107 insertions(+), 330 deletions(-) delete mode 100644 apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx index 40ca55d041..3f20139ab4 100644 --- a/apps/web/src/components/sandbox/preview/preview.tsx +++ b/apps/web/src/components/sandbox/preview/preview.tsx @@ -37,7 +37,6 @@ import { Phone02, RefreshCw01, Tablet01, - Terminal, } from "@untitledui/icons"; import { cn } from "@decocms/ui/lib/utils.ts"; import { Button } from "@decocms/ui/components/button.tsx"; @@ -53,7 +52,6 @@ import { MainPanelHeaderPortal, useMainPanelHeaderSlot, } from "@/layouts/agent-shell-layout/panel-header"; -import { useTerminalVisibility } from "@/layouts/main-panel-tabs/terminal-visibility"; import { DropdownMenu, DropdownMenuContent, @@ -244,7 +242,6 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { const { currentBranch: branch } = useChatTask(); const workspace = useBlocksPreviewWorkspace(); // Toggles the bottom terminal drawer (null on surfaces without the provider). - const terminal = useTerminalVisibility(); const goToTab = (main: string) => { navigate({ @@ -1587,122 +1584,100 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
) : null; - // Overflow menu (⋯) — sits on the right, beside the publish actions. Renders - // whenever there's at least one available action: the Terminal toggle is - // always available (so it stays reachable during boot, before the iframe is - // up), while copy / SEO items are gated on the preview being live. - // Fast Preview is sandbox-less — there is no terminal to show, so the - // toggle is withheld entirely rather than opening an empty drawer. - const terminalToggle = terminal?.available ? terminal : null; - const moreMenu = - showPreviewToolbar || terminalToggle ? ( -
- - - - - - {terminalToggle && ( - - terminalToggle.setVisible(!terminalToggle.visible) - } - > - - {terminalToggle.visible - ? t("sandbox.preview.hideTerminal") - : t("sandbox.preview.showTerminal")} + // Overflow menu (⋯) — copy / SEO actions, gated on the preview being live. + const moreMenu = showPreviewToolbar ? ( +
+ + + + + + {showPreviewToolbar && ( + <> + + + {t("sandbox.preview.copyCurrentUrl")} - )} - {showPreviewToolbar && ( - <> - {terminalToggle && } - - - {t("sandbox.preview.copyCurrentUrl")} - - - )} - {decofile && meta && ( - <> - - {currentPageKey && ( - { - workspace.editSeo({ - kind: "page", - key: currentPageKey, - path: currentPath, - }); - activateEditingMode("blocks"); - }} - > - - {t("sandbox.preview.editSeo")} - - )} - {currentPageKey && ( - { - try { - goToTab( - formatCodeTabId( - decoBlockFileViewPath(currentPageKey), - ), - ); - } catch { - toast.error(t("sandbox.preview.invalidPageBlockKey")); - } - }} - > - - {t("sandbox.preview.viewJson")} - - )} - - )} - {repoDir && ( - <> - + + )} + {decofile && meta && ( + <> + + {currentPageKey && ( window.open(ideDeepLink("vscode", repoDir))} + onClick={() => { + workspace.editSeo({ + kind: "page", + key: currentPageKey, + path: currentPath, + }); + activateEditingMode("blocks"); + }} > - VSCode - {t("sandbox.preview.openInVscode")} + + {t("sandbox.preview.editSeo")} + )} + {currentPageKey && ( window.open(ideDeepLink("cursor", repoDir))} + onClick={() => { + try { + goToTab( + formatCodeTabId(decoBlockFileViewPath(currentPageKey)), + ); + } catch { + toast.error(t("sandbox.preview.invalidPageBlockKey")); + } + }} > - Cursor - {t("sandbox.preview.openInCursor")} + + {t("sandbox.preview.viewJson")} - - )} - - startCmsTour(t)}> - - {t("cmsTour.menuItem")} - - - -
- ) : null; + )} + + )} + {repoDir && ( + <> + + window.open(ideDeepLink("vscode", repoDir))} + > + VSCode + {t("sandbox.preview.openInVscode")} + + window.open(ideDeepLink("cursor", repoDir))} + > + Cursor + {t("sandbox.preview.openInCursor")} + + + )} + + startCmsTour(t)}> + + {t("cmsTour.menuItem")} + +
+
+
+ ) : null; const canVisualEdit = display.mode === "sandbox"; const floatingPreviewControls = canVisualEdit ? ( diff --git a/apps/web/src/hooks/use-preferences.ts b/apps/web/src/hooks/use-preferences.ts index 665bc0c1c7..72fdea06b7 100644 --- a/apps/web/src/hooks/use-preferences.ts +++ b/apps/web/src/hooks/use-preferences.ts @@ -11,12 +11,6 @@ interface Preferences { enableSounds: boolean; theme: ThemeMode; language: Locale; - /** - * Default visibility of the sandbox preview terminal on surfaces that have - * one. `false` keeps the historical opt-in behavior; `true` shows it by - * default. A per-VM Show/Hide choice still overrides this default. - */ - terminalVisibleByDefault: boolean; /** * Task-board lanes hidden by default (`HIDDEN_STATUSES`) that this person has * pulled back onto the board. Statuses, not lane indexes, so a reordered or @@ -31,7 +25,6 @@ const DEFAULT_PREFERENCES: Preferences = { enableSounds: false, theme: "system", language: detectLocale(), - terminalVisibleByDefault: false, shownTaskBoardLanes: [], }; diff --git a/apps/web/src/i18n/en/sandbox.ts b/apps/web/src/i18n/en/sandbox.ts index 3c30fd86d5..148a336906 100644 --- a/apps/web/src/i18n/en/sandbox.ts +++ b/apps/web/src/i18n/en/sandbox.ts @@ -393,8 +393,6 @@ export const sandbox = { "sandbox.preview.exitEditor": "Exit editor", "sandbox.preview.expandTerminal": "Expand terminal", "sandbox.preview.resizeTerminal": "Resize terminal", - "sandbox.preview.hideTerminal": "Hide terminal", - "sandbox.preview.showTerminal": "Show terminal", "sandbox.preview.copyCurrentUrl": "Copy Current URL", "sandbox.preview.createNewPage": "Create new page", "sandbox.preview.devServerPreviewTitle": "Dev Server Preview", diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index 4ca2e2b6e6..eec8dafb5c 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -70,9 +70,6 @@ export const settings = { "settings.preferences.soundsDescription": "Play sounds for agent actions and notifications.", "settings.preferences.soundsPreview": "Preview notification sound", - "settings.preferences.terminalVisible": "Show preview terminal by default", - "settings.preferences.terminalVisibleDescription": - "Open the preview terminal automatically instead of hiding it until you show it.", "settings.preferences.toolApproval": "Tool Approval", "settings.preferences.toolApprovalDescription": "Control how tools are approved before execution.", diff --git a/apps/web/src/i18n/pt-br/sandbox.ts b/apps/web/src/i18n/pt-br/sandbox.ts index 88ca29b2f2..0e991d14d4 100644 --- a/apps/web/src/i18n/pt-br/sandbox.ts +++ b/apps/web/src/i18n/pt-br/sandbox.ts @@ -408,8 +408,6 @@ export const sandbox = { "sandbox.preview.exitEditor": "Sair do editor", "sandbox.preview.expandTerminal": "Expandir terminal", "sandbox.preview.resizeTerminal": "Redimensionar terminal", - "sandbox.preview.hideTerminal": "Ocultar terminal", - "sandbox.preview.showTerminal": "Mostrar terminal", "sandbox.preview.copyCurrentUrl": "Copiar URL atual", "sandbox.preview.createNewPage": "Criar nova página", "sandbox.preview.devServerPreviewTitle": diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 8086ac8105..f2addb0941 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -72,10 +72,6 @@ export const settings = { "settings.preferences.soundsDescription": "Reproduza sons para ações de agentes e notificações.", "settings.preferences.soundsPreview": "Ouvir som de notificação", - "settings.preferences.terminalVisible": - "Mostrar terminal do preview por padrão", - "settings.preferences.terminalVisibleDescription": - "Abrir o terminal do preview automaticamente em vez de mantê-lo oculto até você exibi-lo.", "settings.preferences.toolApproval": "Aprovação de ferramentas", "settings.preferences.toolApprovalDescription": "Controle como as ferramentas são aprovadas antes da execução.", diff --git a/apps/web/src/layouts/main-panel-tabs/drawer-storage.test.ts b/apps/web/src/layouts/main-panel-tabs/drawer-storage.test.ts index 872b83fe30..6d0a093480 100644 --- a/apps/web/src/layouts/main-panel-tabs/drawer-storage.test.ts +++ b/apps/web/src/layouts/main-panel-tabs/drawer-storage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parseDrawerState, parseTerminalOverride } from "./drawer-storage"; +import { parseDrawerState } from "./drawer-storage"; describe("parseDrawerState", () => { it("defaults to closed with no height for a missing value", () => { @@ -39,30 +39,3 @@ describe("parseDrawerState", () => { }); }); }); - -describe("parseTerminalOverride", () => { - it("returns null for a missing value (→ use default preference)", () => { - expect(parseTerminalOverride(null)).toBeNull(); - }); - - it("returns an explicit true override", () => { - expect(parseTerminalOverride(JSON.stringify({ visible: true }))).toBe(true); - }); - - it("returns an explicit false override (a per-VM Hide beats the default)", () => { - expect(parseTerminalOverride(JSON.stringify({ visible: false }))).toBe( - false, - ); - }); - - it("returns null when `visible` is absent or non-boolean", () => { - expect(parseTerminalOverride(JSON.stringify({}))).toBeNull(); - expect( - parseTerminalOverride(JSON.stringify({ visible: "yes" })), - ).toBeNull(); - }); - - it("falls back to null on malformed JSON", () => { - expect(parseTerminalOverride("{not json")).toBeNull(); - }); -}); diff --git a/apps/web/src/layouts/main-panel-tabs/drawer-storage.ts b/apps/web/src/layouts/main-panel-tabs/drawer-storage.ts index d70a70b366..3a1830f116 100644 --- a/apps/web/src/layouts/main-panel-tabs/drawer-storage.ts +++ b/apps/web/src/layouts/main-panel-tabs/drawer-storage.ts @@ -29,18 +29,3 @@ export function parseDrawerState(raw: string | null): DrawerState { return { open: false, height: null }; } } - -/** - * Parse a `preview-terminal-visible:` record into a per-VM override, or - * `null` when the user hasn't set one for this VM (missing/malformed value, or - * a non-boolean `visible` field). - */ -export function parseTerminalOverride(raw: string | null): boolean | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - return typeof parsed.visible === "boolean" ? parsed.visible : null; - } catch { - return null; - } -} diff --git a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx index f442620280..5bf49e46a3 100644 --- a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx +++ b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx @@ -1,7 +1,13 @@ /** * MainPanelWithDrawer — composes the tab body (with its internal per-tab - * ErrorBoundary) above the sandbox PreviewDrawer. The drawer is gated on - * `hasClonableSource` so non-cloneable agents (e.g. decopilot) don't see it. + * ErrorBoundary) above the sandbox PreviewDrawer. + * + * The drawer is mounted whenever the project can have one — a clonable source + * and a daemon behind it — and sits collapsed to its toolbar until the user + * expands it (PreviewDrawerHost persists that per virtualMcpId). There is no + * separate "is the terminal shown" flag: a control that could hide the drawer + * while the drawer stayed mounted is how the console ended up un-dismissable + * in CMS mode. */ import { useSearch } from "@tanstack/react-router"; @@ -12,19 +18,6 @@ import { resolveCmsMode } from "@/sdk/cms-mode"; import { MainPanelContent } from "@/layouts/main-panel-tabs"; import { OVERLAY_TABS } from "./tab-id"; import { PreviewDrawerHost } from "./preview-drawer-host"; -import { - TerminalVisibilityProvider, - useTerminalVisibility, -} from "./terminal-visibility"; - -// Renders the bottom terminal drawer only when the user has toggled it on -// (via the preview's ⋯ menu). Separate component so it can consume the -// visibility context that MainPanelWithDrawer provides. -function TerminalDrawerSlot() { - const terminal = useTerminalVisibility(); - if (!terminal?.visible) return null; - return ; -} export function MainPanelWithDrawer({ virtualMcpId, @@ -36,27 +29,23 @@ export function MainPanelWithDrawer({ const inset = useInsetContext(); const { activeTask } = useChatTask(); const { main } = useSearch({ strict: false }) as { main?: string | 0 }; - // Thread-scoped repo (bound by `load_repo`) also gets the drawer + dev - // terminal, not just agents with their own repo. + /** Thread-scoped repos (bound by `load_repo`) get the drawer too. */ const hasClonableSource = agentHasClonableSource(inset?.entity?.metadata) || agentHasClonableSource(activeTask?.metadata); + // CMS mode is sandbox-less — no daemon for a terminal to attach to. + const hasDaemon = !resolveCmsMode(inset?.entity?.metadata).active; const showDrawer = - hasClonableSource && !(typeof main === "string" && OVERLAY_TABS.has(main)); - // CMS mode is sandbox-less — there is no daemon for a terminal to attach to. - const terminalAvailable = !resolveCmsMode(inset?.entity?.metadata).active; + hasClonableSource && + hasDaemon && + !(typeof main === "string" && OVERLAY_TABS.has(main)); return ( - -
-
- -
- {showDrawer && } +
+
+
- + {showDrawer && } +
); } diff --git a/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx b/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx deleted file mode 100644 index 1fb061d13a..0000000000 --- a/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Terminal-visibility state shared between the preview's ⋯ menu (which toggles - * it) and MainPanelWithDrawer (which gates the bottom terminal drawer on it). - * - * Default visibility comes from the user's `terminalVisibleByDefault` - * preference (Settings → Preferences). A per-virtualMcpId Show/Hide choice - * overrides that default and is persisted so it survives navigation and - * sandbox restarts (once enabled it also shows during subsequent boots, so - * clone/install logs are visible again). - */ - -import { createContext, use, useRef, useState, type ReactNode } from "react"; -import { usePreferences } from "@/hooks/use-preferences.ts"; -import { parseTerminalOverride } from "./drawer-storage"; - -const STORAGE_KEY = (id: string) => `preview-terminal-visible:${id}`; - -/** Per-VM override, or `null` when the user hasn't set one for this VM. */ -function readPersisted(id: string): boolean | null { - try { - return parseTerminalOverride(localStorage.getItem(STORAGE_KEY(id))); - } catch { - return null; - } -} - -function writePersisted(id: string, visible: boolean): void { - try { - localStorage.setItem(STORAGE_KEY(id), JSON.stringify({ visible })); - } catch { - /* ignore */ - } -} - -interface TerminalVisibilityCtx { - /** - * Whether this project can have a terminal at all. False when there is no - * daemon behind it (CMS mode). Consumers gate their CONTROLS on this; they - * must not re-derive the condition, or the control and the surface it - * toggles can disagree. - */ - available: boolean; - visible: boolean; - setVisible: (visible: boolean) => void; -} - -const TerminalVisibilityContext = createContext( - null, -); - -export function TerminalVisibilityProvider({ - virtualMcpId, - available = true, - children, -}: { - virtualMcpId: string | null; - /** False when the project has no daemon to attach to (CMS mode). */ - available?: boolean; - children: ReactNode; -}) { - const storageKey = virtualMcpId ?? "__no-vmcp__"; - const [preferences] = usePreferences(); - // `null` = no per-VM override → fall back to the user's default preference. - const [override, setOverrideState] = useState(null); - - // Re-hydrate when the VM changes (render-time setState gated by a ref — - // idiomatic here; useEffect is banned for derived state). - const lastKeyRef = useRef(null); - // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- hydrate on VM switch - if (lastKeyRef.current !== storageKey) { - // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- hydrate on VM switch - lastKeyRef.current = storageKey; - setOverrideState(readPersisted(storageKey)); - } - - const setVisible = (next: boolean) => { - if (!available) return; - setOverrideState(next); - writePersisted(storageKey, next); - }; - - return ( - - {children} - - ); -} - -/** Returns null when rendered outside a provider (e.g. non-sandbox surfaces). */ -export function useTerminalVisibility(): TerminalVisibilityCtx | null { - return use(TerminalVisibilityContext); -} diff --git a/apps/web/src/views/settings/profile-preferences.tsx b/apps/web/src/views/settings/profile-preferences.tsx index 61b2e71284..fd782896dd 100644 --- a/apps/web/src/views/settings/profile-preferences.tsx +++ b/apps/web/src/views/settings/profile-preferences.tsx @@ -292,33 +292,6 @@ function PreferencesSection() {
} /> - { - track("preferences_terminal_default_toggled", { - enabled: !preferences.terminalVisibleByDefault, - }); - setPreferences((prev) => ({ - ...prev, - terminalVisibleByDefault: !prev.terminalVisibleByDefault, - })); - }} - action={ - { - track("preferences_terminal_default_toggled", { - enabled: checked, - }); - setPreferences((prev) => ({ - ...prev, - terminalVisibleByDefault: checked, - })); - }} - /> - } - /> Date: Mon, 17 Aug 2026 10:31:38 -0300 Subject: [PATCH 05/19] fix(cms-mode): stop the sandbox lifecycle gating Content and the Code tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more surfaces assumed a sandbox. The Content tab bailed to SandboxStateRenderer because `previewState.kind !== "iframe"`, then treated every phase before `running` as warming — so CMS mode, whose phase never leaves "idle", showed "cloning your repo" forever. The tab itself was gated the same way: `devServerReady` fed the decofile fetch, so `hasEditableDecoContent` never saw data. `resolveBlocksTabState` already documents the rule — "the lifecycle phase stays idle forever and must not gate the panel; data readiness alone decides" — it just was not applied here. Both now bypass the lifecycle in CMS mode and read the decofile over HTTP. Also splits the Preview/Code pair `getSourceSystemTabs` shipped as one unit. Code browses the sandbox filesystem, which CMS mode does not have, so CMS mode gets Preview + Content and vibecoding keeps Preview + Code. Verified on a live CMS project: Content loads 677 pages, 10 sections, 25 loaders; the tab bar reads Preview · Content with no Code. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/sandbox/content/content-browser.tsx | 9 +++++++-- .../main-panel-tabs/source-system-tabs.test.ts | 10 ++++++++++ .../src/layouts/main-panel-tabs/source-system-tabs.ts | 11 ++++++++++- .../layouts/main-panel-tabs/use-main-panel-tabs.ts | 11 +++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index 01309f2993..3314efd6e6 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -64,6 +64,7 @@ import { createReferencedBlockSaver } from "@/components/sections-editor/save-re import { CollectionsSidebar } from "./collections-sidebar"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { SandboxStateRenderer } from "./sandbox-state-renderer"; import { buildDuplicatePage, @@ -250,6 +251,7 @@ export function ContentBrowser({ mode = "content" }: ContentBrowserProps) { const { org } = useProjectContext(); const virtualMcpId = inset?.entity?.id ?? null; + const cmsModeActive = resolveCmsMode(inset?.entity?.metadata).active; const vmEvents = useSandboxEvents(); // Resolve the sandbox from the shared lifecycle context — the same source @@ -261,7 +263,8 @@ export function ContentBrowser({ mode = "content" }: ContentBrowserProps) { const previewUrl = lifecycle.previewUrl; const sandboxState = lifecycle.previewState; - if (sandboxState.kind !== "iframe") { + // CMS mode has no sandbox to boot, so its state must not gate this view. + if (!cmsModeActive && sandboxState.kind !== "iframe") { return ( { test("returns no source tabs without clonable source", () => { expect(getSourceSystemTabs(false)).toEqual([]); }); + + test("drops Code without a sandbox — CMS mode edits via Content", () => { + expect(getSourceSystemTabs(true, false)).toEqual([ + { id: "preview", title: "Preview" }, + ]); + }); + + test("a sandbox-less source with no clonable repo still yields nothing", () => { + expect(getSourceSystemTabs(false, false)).toEqual([]); + }); }); describe("shouldDeepLinkSourceTab", () => { diff --git a/apps/web/src/layouts/main-panel-tabs/source-system-tabs.ts b/apps/web/src/layouts/main-panel-tabs/source-system-tabs.ts index 7bb15c762b..6c026b9235 100644 --- a/apps/web/src/layouts/main-panel-tabs/source-system-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/source-system-tabs.ts @@ -8,10 +8,19 @@ const SOURCE_SYSTEM_TABS: readonly SourceSystemTab[] = [ { id: "code", title: "Code" }, ]; +/** + * Preview is available to any clonable source. Code is not: it browses the + * sandbox filesystem, which CMS mode does not have — there the decofile is + * read over HTTP and Content is the editing surface instead. + */ export function getSourceSystemTabs( hasClonableSource: boolean, + hasSandbox = true, ): SourceSystemTab[] { - return hasClonableSource ? [...SOURCE_SYSTEM_TABS] : []; + if (!hasClonableSource) return []; + return SOURCE_SYSTEM_TABS.filter( + (tab) => hasSandbox || tab.id !== "code", + ).map((tab) => ({ ...tab })); } /** diff --git a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index 11a1ab8f88..9ec5c1a6ac 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -43,6 +43,7 @@ import { useLiveMeta } from "@/components/sections-editor/use-live-meta"; import { hasEditableDecoContent } from "@/components/sections-editor/page-list"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import type { ThreadExpandedTool, ThreadMetadata, @@ -218,7 +219,10 @@ export function useMainPanelTabs(ctx: { // SandboxEventsProvider (desktop tabs bar lives inside VmEventsBridge). const vmEvents = useSandboxEvents(); const { vmEntry, previewUrl } = useSandboxLifecycle(); - const devServerReady = vmEvents.lifecycle.phase === "running"; + const cmsModeActive = resolveCmsMode(entity?.metadata).active; + // CMS mode reads the decofile over HTTP; the lifecycle never leaves "idle". + const devServerReady = + cmsModeActive || vmEvents.lifecycle.phase === "running"; // A user-desktop sandbox serves its dev server on a loopback previewUrl // (`http://.localhost`), which the cloud proxy cannot reach — so the @@ -349,7 +353,10 @@ export function useMainPanelTabs(ctx: { // have a mirrored `githubRepo`. Clicking from off the Report Agent deep-links // into it (see setActiveTab). leadingSystemTabs.push( - ...getSourceSystemTabs(hasClonableSource || reportsOnly).map((tab) => ({ + ...getSourceSystemTabs( + hasClonableSource || reportsOnly, + !cmsModeActive, + ).map((tab) => ({ id: tab.id, title: tab.id === "preview" From 1e3514f5151a58de610316796e2d896cf76e2167 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 10:57:27 -0300 Subject: [PATCH 06/19] fix(cms-tour): let the CMS tour run in CMS mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tour gated eligibility on `lifecycle.phase === "running"`, which CMS mode never reaches — so the walkthrough built to teach the CMS only ever ran on sandbox projects. Readiness now also accepts CMS mode, where the surface is up immediately. The launch path already polls for the lead anchor and filters steps to visible ones, so the sandbox-less case degrades on its own: four steps resolve in CMS mode (Preview, page dropdown, CMS toggle, branches) and the visual-editor step drops out, since it needs an origin we can inject into. Verified on a live CMS project: previewReady true with phase "idle", four visible steps, tour marks itself seen. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/cms-tour/cms-tour.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/cms-tour/cms-tour.tsx b/apps/web/src/components/cms-tour/cms-tour.tsx index 6163de84c2..61ac46a485 100644 --- a/apps/web/src/components/cms-tour/cms-tour.tsx +++ b/apps/web/src/components/cms-tour/cms-tour.tsx @@ -21,6 +21,7 @@ import type { Config, Driver, DriveStep } from "driver.js"; import "driver.js/dist/driver.css"; import "./cms-tour.css"; import { authClient } from "@/lib/auth-client"; +import { resolveCmsMode } from "@/sdk/cms-mode"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useVirtualMCP } from "@/sdk"; @@ -30,13 +31,7 @@ import { useT, type TFunction } from "@/i18n/use-t"; import { tourAnchorSelector } from "./anchors"; import { buildSteps } from "./steps"; -/** - * The tour only starts once the preview toolbar is actually on screen. The CMS - * toggle anchors an early step and only exists when the Preview view is open - * with its toolbar rendered (dev server up), so gating on it — not merely the - * Preview root, which can be mounted-but-hidden behind another tab — keeps the - * tour from launching in a context where its controls are missing. - */ +/** The tour waits for its lead control — the CMS toggle — to be on screen. */ const READY_SELECTOR = tourAnchorSelector("edit"); const seenFlag = (userId: string) => @@ -184,7 +179,10 @@ export function CmsTour({ virtualMcpId }: { virtualMcpId: string }) { const userId = session?.user?.id; const isCodeAgent = agentHasClonableSource(entity?.metadata); - const previewReady = vmEvents.lifecycle.phase === "running"; + // CMS mode has no dev server to wait for — its surface is up immediately. + const previewReady = + resolveCmsMode(entity?.metadata).active || + vmEvents.lifecycle.phase === "running"; const eligible = isCodeAgent && previewReady && !!userId; const [launched, setLaunched] = useState(false); From 8b5f6e572eb00f4dad07d9432cdda800e2818ea3 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 11:35:20 -0300 Subject: [PATCH 07/19] chore(cms-mode): drop the design docs from the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec and plan were working notes for this change, not documentation the repo should carry — and they had already drifted from what shipped. Their content stays in this branch's history. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/docs/cms-mode-plan.md | 283 --------------------------------- apps/web/docs/cms-mode-spec.md | 209 ------------------------ 2 files changed, 492 deletions(-) delete mode 100644 apps/web/docs/cms-mode-plan.md delete mode 100644 apps/web/docs/cms-mode-spec.md diff --git a/apps/web/docs/cms-mode-plan.md b/apps/web/docs/cms-mode-plan.md deleted file mode 100644 index d5a180793f..0000000000 --- a/apps/web/docs/cms-mode-plan.md +++ /dev/null @@ -1,283 +0,0 @@ -# CMS mode / Vibecoding mode — implementation plan - -Implements [`cms-mode-spec.md`](./cms-mode-spec.md). - -> **Revised after a 7-perspective critique.** The first draft was one PR built on five false premises. It is -> now five PRs built on verified ones. See [Critique decisions](#critique-decisions). - -> **Rebased on `main` (`76d13142b`), after PR #6054.** All pointers below were re-verified against the -> rebased tree — every finding survived; only `preview.tsx` line numbers moved, and they are resynced. -> `previewOrigin(previewUrl)` now has **five** call sites (`:876, 915, 925, 932, 954`), not two. - -**Blast radius, counted not estimated:** `fastPreview`/`fast-preview` appears **211 times across 34 files in -4 workspaces** (`apps/web`, `apps/api`, `packages/shared`, `packages/e2e`), plus ~20 more files for the panel -work. One PR is not reviewable and its rollback story does not hold once a project has written -`metadata.cmsMode`. - -Line numbers drift — treat them as pointers. - ---- - -## ~~PR 0 · Redact credentials from daemon console output~~ — cut, file separately - -**Not in scope.** It was justified by PR 5's "console always available during boot". With PR 5 cut, re-keying -the console gate from `fastPreviewEnabled` to "a pod exists" is a **no-op in practice** — CMS mode implies no -pod, so exactly the same people see the console in exactly the same situations as today. This work does not -promote the leak. - -**It is still a real bug and should be filed on its own:** -`clone.go:444` puts the clone URL (`https://x-access-token:ghs_…@github.com/…`) in argv, `:92` echoes argv -verbatim via `OnChunk`, and `events/broadcast.go:94` also appends it to the **replay buffer**, so it is -readable after boot by anyone with workspace access. Fix is `stripCredentials` -(`internal/setup/install.go:23`) applied in `formatArgv`, failing closed, asserted on the emitted bytes in -`packages/sandbox/daemon-e2e/` (CLAUDE.md #8). Independent of this plan in both directions. - ---- - -## PR 1 · Rename, all four workspaces - -Mechanical, boring, and reviewable *because* it is boring. **Not** "no behaviour change" — it touches an -authorization input. - -- Put the dual-read in **`packages/shared`** (beside `resolvePreviewServerUrl`, whose `productionUrl` alias - is the precedent) so web *and* api inherit it. `resolveFastPreview` → `resolveCmsMode`. -- **Consume it from all four current copies of the gate.** It is not "in ONE place" as its own doc claims: - - `apps/web/src/sdk/fast-preview.ts` (the helper) - - `apps/web/src/components/sandbox/preview/preview.tsx:373` — **inlines the gate**, never imports the - helper. Fix this first or the rename skips the file PRs 3–4 edit most. - - `apps/api/src/api/routes/decofile.ts:124` - - `apps/api/src/api/routes/sandbox-proxy.ts:213` -- **Keep writing `fastPreview` in this PR.** Read both, write old. The write flips only once every reader - ships. The metadata object is `.loose()`, so a premature `cmsMode` write type-checks and fails only against - a real server. -- Add `cmsMode` to the schema (`packages/shared/src/sdk/types/virtual-mcp.ts` — 3 sites) and run - `bun run --cwd=apps/api generate:tool-contracts`. -- i18n: rename keys **and retranslate pt-br values** — `bun run check` proves key completeness, not that - `pt-br/sandbox.ts:542` stopped saying "Preview Rápido". -- Leave `FAST_PREVIEW_CACHE_DIR` (`apps/api/src/decofile/disk-cache.ts`) alone — it is a deploy-config env - var. State this explicitly so it does not read as an oversight. -- **New e2e:** write `cmsMode` only, then hit the decofile route. The existing suite seeds `fastPreview` - directly (`packages/e2e/tests/decofile-api.spec.ts:161`), so it cannot catch this class of break. - -**Tests to update:** `blocks-tab-state.test.ts`, `preview-display.test.ts`, `section-preview-url.test.ts`, -`sandbox-lifecycle-context.test.ts`, `decofile-api.spec.ts` (asserts the literal 404 string at `:265`). - ---- - -## PR 2 · Widen `SidePanelKind` — all of it - -`use-layout-state.ts:24` → `"chat" | "cms"`. The type is the easy half; **six** files hardcode the literal, -and three fail silently rather than at compile time: - -| File | Line | Failure | -| --- | --- | --- | -| `router.tsx` | 298 | zod `"chat" \| 0` — **`?sidepanel=cms` is rejected** | -| `lib/thread-layout-memory.ts` | 27, 43 | **silently drops** the kind from layout memory | -| `chat/hooks/use-chat-navigation.ts` | 47 | **silently closes** the panel on thread navigation | -| `layouts/resolve-task-switch-search.ts` | 60 | compile error | -| `layouts/shell-layout.tsx` | 97, 119 | compile error | -| `main-panel-tabs/mobile-main-panel-tab-select.tsx` | 142 | hardcoded | - -Also mode-aware the three hardcoded `"chat"` defaults — `withWorkspaceFallback` (`:93`), -`resolveDefaultPanelState` (`:111`), and `resolveMobileSurface` (`:180`), which the first draft missed. -`MobileWorkspaceSurface = SidePanelKind | "main"` widens for free. - -`withWorkspaceFallback` is **module-private and not in the test file** — test it through -`resolveDefaultPanelState` rather than exporting it (keeps knip quiet). - -Parse unknown values to the union at the boundary; unit-test that `?sidepanel=junk` degrades to the default. -The rollback story assumes this and nothing currently provides it. - -**Tests:** update `use-layout-state.test.ts` (all 10 cases), add round-trip cases for -`use-chat-navigation` + `thread-layout-memory` — the two silent ones. - ---- - -## PR 3 · De-duplicate before building - -No behaviour change. Each item shrinks a later PR. - -1. **Delete `ContentBrowser`'s dead `mode="blocks"` path.** `content-tab.tsx:31` renders `` - with no `mode`, so ~10 branches (`content-browser.tsx:351, 357, 364, 378, 1032, 1050, 1117, 1340` + the - prop at `:243-247, 298, 336`) are unreachable. **`knip` will not find this** — it reports unused exports, - not unreachable prop branches. -2. **Extract `redirectIfInvisible()`** from `use-main-panel-tabs.ts:310-319`, where the `git` condition is - written twice and `content` is handled in one arm but not the other. Cover `git`/`content`/`code` - uniformly. There is **no `use-main-panel-tabs.test.ts`** — extracting is what makes this testable. -3. **Extract a shared ``** — the `lazy(() => import(sections-editor))` wrapper and the - `page:${k}` / `section:${k}` key builder are written twice (`blocks-panel.tsx:26-30, 110-114` and - `content-browser.tsx:145-149, 1315-1319`), and have already drifted: - `onVariantPreviewOverride` is passed by only one. - ---- - -## PR 4 · The CMS panel moves into the side panel - -Gated to projects where CMS mode is available (`resolveCmsMode(metadata).active`) — **not** to every project -with content, per the spec's corrected prerequisite. - -- `workspace-panel-group.tsx:324` — replace `FastPreviewChatNotice` with `BlocksPanel`. - `BlocksPreviewWorkspaceProvider` already sits above the panel group (`agent-shell-layout/index.tsx:326`), - so no state lifting. -- Add `CmsToggle` beside `ChatToggle`; carry `disableActiveSidePanelToggle` as `ChatToggle` does. -- **`resolveBlocksTabState` must be re-keyed too** (`blocks-tab-state.ts:44, 92`). It takes the gate as an - explicit input and was missing from the first draft's list — without it the panel renders a permanent - spinner. -- **Do not delete `chat.input.fastPreviewComingSoon`** — `input.tsx:644` still uses it. Delete only the - component; the key is re-copied in PR 5. -- **Move the CMS tour anchor.** `TOUR_ANCHORS.edit` is the tour's readiness gate - (`cms-tour.tsx:40` `READY_SELECTOR`) and lives on the button PR 5 deletes. Move it to `CmsToggle`; update - `steps.ts` + `steps.test.ts`. `` renders at `workspace-panel-group.tsx:292` — the file this PR edits. -- **Relocate `BlocksPanel`'s state components** (`MainPanelLoading`, `BlocksEmptyState`, `BlocksErrorState`) - out of `layouts/main-panel-tabs` — side-panel content should not depend on main-panel layout modules. -- ~~Disable `onActivate` in CMS mode.~~ **Dropped — #6054 solved it.** CMS mode no longer mounts - `HeaderActions` at all (`header-info.tsx:28-32`), so `send()` / `openSidePanel("chat")` is unreachable there. -- Tabs, console and preview origin follow **pod presence**, not the panel — so no tab-redirect logic is - needed here, and a developer opening the CMS panel keeps Code and Review changes. -- Follow the **mount-boundary pattern** from `header-info.tsx`: branch and mount a different component, - rather than threading a mode input into a shared one. `workspace-panel-group.tsx:324` already has the - identical shape. -- Reuse **`isCmsStateSettling`**'s rule for the panel's save indicator. Note `use-save-block.ts` / - `use-delete-block.ts` now **await** their status invalidation (changed in #6054, shared with vibecoding), - so the indicator already stays lit until the re-read lands. - -> The 320px floor already exists (`workspace-panel-group.tsx:297` -> `[&>[data-workspace-panel-open]]:!min-w-[320px]`). The first draft's 250px measurement tested an -> unreachable width. Drop the per-kind width storage — speculative, and it forks a localStorage key with no -> migration. - -**Click-through selection is deferred to PR 5** — it depends on the origin decision, which is a security -question, not a refactor. See the spec's open question 1. - ---- - -## PR 5 · The boot flow and the origin decision - -> **Recommend cutting.** Two critics called it speculative; I kept it because it was explicitly asked for. -> #6054 has since made it *harder*, not easier, and that tips the balance. -> -> The shipped design makes CMS ⟹ no pod structural: `header-info.tsx` branches at the mount specifically so -> that lifecycle hooks never mount on a CMS surface, and `SelectCmsHeaderButtonInput` has no pod input at -> all. "CMS project that also boots a pod" now means unwinding a deliberate, tested, merged decision — -> and the CMS header would have to grow a pod concept it was just designed to be free of. -> -> The cheaper path to the same user need is the **handoff**: a CMS project that needs code hands off to a -> vibecoding surface, rather than growing one in place. Keep the section below as the record of what state 2 -> would cost; do not build it without a fresh decision. - -Behind its own **default-off flag** (CLAUDE.md checklist #7 — this is a boot/dispatch hot path). - -- Start prompt → user-driven `lifecycle.start()`. Precedent: `content-browser.tsx:270`. -- Intent must not re-dress the workspace: while the pod is cold, tabs/console/origin stay as they are. -- Add an explicit **stop** control to the switch — gating the console on pod presence removes the preview - drawer, today the only `onStop` for hosted pods. -- Drop the preview SSE when in CMS mode with no pending boot (`agent-shell-layout/index.tsx:283`), so a CMS - tab stops renewing the pod claim every 5 minutes. -- Touch `shouldAutoStart` (`sandbox-lifecycle-context.tsx:66`) — it still auto-boots for non-gated projects, - contradicting rule 1. -- Guard `start()` on `startVm.isPending` (`:749`), which the auto-start path already does. -- Re-gate `input.tsx:642` on pod absence, with **new copy** — the key's meaning changes. -- **Keep `draftPreviewUrl` keyed on the metadata gate**, not the mode. It is why entering vibecoding is not - an iframe remount; re-keying it "for consistency" would turn that into a cross-origin navigation. -- Retire the nested pane + `Edit content` toggle (`preview.tsx:1845-1863, 1244`), and the **four** - `activateEditingMode("blocks")` callers (`:999, 1164, 1215, 1643`). Collapse `PreviewEditingMode` to - `"preview" | "visual"` and retire `cmsDefaultOpen` / `shouldAutoOpenCms` with it. Then `knip`. -- **The origin decision** (spec open question 1) — if taken: re-derive `previewOrigin` from the iframe's - actual base, `null` during swaps, add the `e.source` check, and add an e2e asserting a wrong-origin message - is rejected. - ---- - -## Testing - -Two tiers, no third ([`TESTING.md`](../../../TESTING.md)). - -**Unit** — all proposed tests are over genuinely pure functions; nothing needs `mock.module` or a stubbed -context. Write phase cases against the **real** `LifecycleState["phase"]` union -(`idle | cloning | checking-out | installing | starting | running | crashed | clone-failed | install-failed | start-failed`) -— the first draft's `"cold"` does not exist. - -Add: `redirectIfInvisible` for `git`/`content`/`code` covering **both** `activeTab` and `mainOpen` (the -existing asymmetry at `:316-319` is untested); `resolveCmsMode` legacy-key fallback; unknown search-param -degradation; `resolveWorkspacePanelAction` when the requested kind is unavailable. - -`blocks-preview-workspace-state.test.ts:41-52` asserts the whole state object with `toEqual` — adding -`sectionClick` breaks it. (The reducer is in `blocks-preview-workspace-state.ts`, not `-context.tsx`.) - -**E2E** — promote the CMS-project fixture out of `decofile-api.spec.ts:76-180` (per `TESTING.md:84`, second -use). Note `plugins/ban-e2e-app-imports.js` allows only `@playwright/test`, `pg`, `zod`, -`@modelcontextprotocol/sdk`, `@decocms/shared`. - -**Cases 5–7 of the first draft are not writable** — no e2e boots a pod; the suite has no sandbox provider or -lifecycle SSE source. Scope them to what is observable (start prompt appears; tabs/console unchanged; -confirming issues exactly one `SANDBOX_START`) and move phase transitions to unit tests, or budget a -lifecycle-stub fixture. - -**Inversions** — the first draft's three-string grep is insufficient. Files that break: -`use-layout-state.test.ts`, `source-system-tabs.test.ts`, `preview-display.test.ts`, -`blocks-tab-state.test.ts` (a whole `describe("sandbox-less Fast Preview")` whose premise this deletes), -`sandbox-lifecycle-context.test.ts`, `section-preview-url.test.ts`, `blocks-preview-workspace-state.test.ts`, -`decofile-api.spec.ts`, `standalone-blocks-panel.spec.ts`. Also `tab-id.test.ts:257`, which keeps passing -while its comment becomes a lie. - ---- - -## Critique decisions - -**Adopted** - -- Deleted `WorkspaceMode`, `resolveWorkspaceMode`, `?mode=`, `committed` and `devFrameReady`. All four - duplicated existing signals, `podPhase !== "cold"` referenced a non-existent phase, and - `devFrameReady === "running"` was *weaker* than `resolvePreviewDisplay`'s existing rule. -- Tabs/console follow **pod presence**, not mode — removes the tab-bounce bug and the redirect logic. -- CMS mode requires the metadata gate; `hasEditableDecoContent` is not the availability signal. -- Rename extended to `apps/api` + `packages/shared`, write stays on the old key. -- Split into five PRs; added PR 0 (credential redaction) and PR 3 (de-duplication). -- Added: `resolveBlocksTabState`, `shouldAutoStart`, the CMS tour anchor, the six `SidePanelKind` literal - sites, the stop control, the SSE/TTL renewal, `onActivate` in CMS mode, `draftPreviewUrl` staying keyed. -- Added a security section; corrected the rollback claim and the 250px width finding. -- Dropped per-kind panel width (speculative) and the first draft's unwritable e2e cases. - -**Rejected** - -- *"Cut the boot flow entirely."* Kept as PR 5 behind a default-off flag. It is the state the user explicitly - asked to design, and gating it is enough to bound the risk. -- *"Keep the `Content` tab out of the switch's concerns."* No change needed — it already stays. - -**The CMS exit ("the honest wall") — cut from this plan** - -Sketched four options; the wall was preferred. It is **not** in any PR here. It is pre-existing (the same -wall exists in today's nested pane), so moving the panel neither creates nor worsens it — and the contextual -version I sketched is not buildable, because the block form has no signal for *what the editor wanted*. See -the spec's [The wall](./cms-mode-spec.md#the-wall--noted-deliberately-not-solved-here). If picked up, it is a -standalone ~2-i18n-key change that applies to the old pane and the new one alike. - -**Revised again after PR #6054 merged** - -- Dropped the `onActivate` guard from PR 4 — the collision it addressed no longer exists. -- Adopted the **mount-boundary pattern** as the governing rule, replacing "thread a mode into shared - components". It was already the shape of PR 4; now it is precedent rather than invention. -- **Flipped my earlier rejection: PR 5 is now recommended for cutting.** The shipped code makes CMS ⟹ no pod - structural, so state 2 costs more than it did when I kept it. -- PR 1 shrinks slightly — the new files already use CMS vocabulary (`cms-panel-state`, `cms-header-actions`, - `thread.cmsActions.*`) while the gate is still `resolveFastPreview`, so the codebase is now *half*-renamed - and inconsistent. That raises the value of finishing it. -- Noted `use-save-block` / `use-delete-block` now await status invalidation (shared with vibecoding). - -**Adapted** - -- *"Restrict scope to Fast Preview projects."* Adopted in substance — CMS mode requires the gate — but the - **rename stands**, so the user-facing vocabulary is still two modes with no "Fast Preview" anywhere. The - gate becomes "has a preview server", not a feature flag. -- *"Delete `PreviewEditingMode`'s `blocks` value."* Deferred to PR 5 where its three callers are removed, - rather than done early. - ---- - -## Checklist - -- [ ] `bun run fmt` · `bun run lint` · `bun run check` · `bun test` · `knip` -- [ ] `bun run --cwd=apps/api generate:tool-contracts` (PR 1) -- [ ] pt-br **values** retranslated, not just keys renamed -- [ ] PR 5 ships default-off -- [ ] Screenshots: CMS mode, boot prompt, booting, vibecoding, unavailable-CMS project diff --git a/apps/web/docs/cms-mode-spec.md b/apps/web/docs/cms-mode-spec.md deleted file mode 100644 index 1093a37df5..0000000000 --- a/apps/web/docs/cms-mode-spec.md +++ /dev/null @@ -1,209 +0,0 @@ -# CMS mode / Vibecoding mode — spec - -**Status:** proposed, revised after critique · **Scope:** `apps/web` shell, preview, side panel · `apps/api` gate - -> Revised after a 7-perspective review. Five load-bearing claims in the first draft were false against -> source. See [Critique decisions](./cms-mode-plan.md#critique-decisions) in the plan. - -## Summary - -Studio's editing workspace has two audiences and one undifferentiated UI. This spec gives it two modes: - -| Mode | For | Cost | -| --- | --- | --- | -| **CMS mode** | Content editors. Blocks, copy, images, page layout. | Free — *when the project has a preview server* | -| **Vibecoding mode** | Developers. Components, logic, dependencies. | A pod, ~1 min boot | - -"Fast Preview" is retired as a **name**. It survives as a **prerequisite**: CMS mode is only pod-less on -projects with a preview server URL, because that is the only configuration where the decofile is reachable -over HTTP instead of through the sandbox daemon. - ---- - -## The prerequisite (corrected) - -The first draft claimed CMS mode is free wherever a site has content. **That is false**, and it is the -correction that most changes the design. - -The pod-less CMS data path exists only behind the persisted gate: - -| Operation | Gate on | Gate off | -| --- | --- | --- | -| Read decofile | `fetchDecofile` — GitHub API (`use-decofile.ts:55`) | `readCommittedJson` — **through the daemon** | -| Write block | `patchDecofile` — GitHub API (`use-save-block.ts:51`) | `POST …/sandbox/…/write` — **the pod's filesystem** | -| Panel state | bypasses lifecycle (`blocks-tab-state.ts:44`) | `classifyPhase("idle")` → `loading`, **forever** | - -Server-side the same gate guards the route CMS mode runs on: `decofile.ts:124` 404s without it, and -`sandbox-proxy.ts:213` needs it to answer `/git/*` from GitHub. - -Two consequences: - -1. **CMS mode requires a preview server URL.** Without one there is no free mode — the honest product answer - is "set a preview server to enable CMS mode", surfaced in settings, not a mode that spins forever. -2. **`hasEditableDecoContent` cannot be the availability signal.** It is derived from the decofile, which off - the gate needs a pod. You would have to boot to discover you did not need to. Availability is the - **metadata gate**; content presence only decides whether the panel has anything to show. - -``` -cmsModeAvailable = resolveCmsMode(metadata).active // cmsMode|fastPreview && previewServerUrl -``` - ---- - -## Model — derived, not invented - -The first draft added a `WorkspaceMode` enum, a `?mode=` search param, and `committed` / `devFrameReady` -booleans. **All four are deleted.** Every signal already exists: - -| Concept | Source of truth | -| --- | --- | -| Which mode the user wants | `SidePanelKind` — `"chat" \| "cms"`, already in the URL | -| Is there a pod | `vmEntry` in the sandbox lifecycle — the same predicate `shouldAutoStart` uses | -| Is the dev server showable | `resolvePreviewDisplay` — keep its `progressStatus !== "doing"` rule, which deliberately admits `failed`/`crashed` so the daemon status page renders | -| Is CMS worth offering | `resolveCmsMode(metadata).active` | - -One intent bit, already in the URL, already persisted per thread. A second `?mode=` param would be a second -copy of the same intent that must be kept in lockstep — and `?mode=code&sidepanel=cms` would be constructible. - -**Mode is `sidePanel === "cms" ? "cms" : "code"`.** Nothing more. - -### Tabs and console follow the pod, not the panel - -The first draft keyed tabs on the mode. That is a state-loss bug: a developer with a pod running who opens -the CMS panel to fix a paragraph would lose Code and Review changes, and get bounced off whichever tab they -were on. - -- **Code · Review changes · console** ← **a pod exists** -- **Side panel contents** ← `SidePanelKind` -- **Preview origin** ← `resolvePreviewDisplay`, unchanged rules - -This removes the redirect logic the first draft needed, and fixes the bounce for free. - ---- - -## Rules - -### In scope - -1. **CMS mode requires a preview server URL** — see [The prerequisite](#the-prerequisite-corrected). -2. **No switch when CMS is unavailable.** The control disappears rather than showing a disabled half. -3. **Side panel contents follow `SidePanelKind`; tabs and console follow pod presence.** A developer with a - pod who opens the CMS panel keeps Code and Review changes — the panel kind must not bounce them off a tab. - -### Parked with PR 5 (the boot flow) - -These only bind once a CMS project can boot a pod. Recorded so they are not rediscovered late. - -4. **Entering vibecoding is explicit and costed.** Never auto-start on a stray click — `shouldAutoStart` is - gated because an accidental start once leaked one pod per new chat (`sandbox-lifecycle-context.tsx:50`). -5. **Cold vs. warm is visible on the switch**, and the warm dot is the **stop** control — see rule 6. -6. **Leaving vibecoding does not kill the pod, but the user must be able to.** Gating the console on pod - presence would remove the preview drawer, which today holds the only `onStop` for hosted pods - (`preview-drawer-host.tsx:115`). *No regression today, because CMS mode has no pod to stop.* -7. **A CMS tab should not hold a pod claim open.** The preview SSE calls `renewTtl` every 5 minutes - (`sandbox-events-handler.ts:124`), extending shutdown for as long as a tab is open. *Only reachable if a - CMS tab can coexist with a pod.* -8. **CMS keeps working while a pod runs — with one caveat.** Reads go to the GitHub branch head while the - agent edits the working tree. Two content sources on one screen; which wins is an open question. - ---- - -## Security - -**For PRs 1–4: nothing new.** The panel move changes which column renders the block editor. It does not -touch the preview iframe's origin, does not enable canvas click-through, and does not promote the console. -Each of those was a consequence of the boot flow, which is cut. - -The three findings below are **gates on PR 5**, recorded so they are not rediscovered late if it is revived. -The first is also a live bug worth filing independently of this work. - -**Daemon console leaks a credential.** `clone.go:444` puts the clone URL in argv, `:92` echoes argv verbatim, -and `broadcast.go:94` keeps it in the replay buffer — so `https://x-access-token:ghs_…@github.com/…` is -readable after boot. **Pre-existing and not promoted by this work** (CMS mode implies no pod, so the console -is shown to exactly the same people as today). File it on its own. - -**The origin trust boundary — a gate on click-through.** `previewOrigin` derives the postMessage allow-list -from the *sandbox* URL (`preview.tsx:876`), and `preview.tsx:1981` deliberately skips editor injection for -non-sandbox frames: *"the production fallback is a view-only, cross-origin frame."* With no pod, -`previewOrigin` returns `null` (`:194-200`) and both the listener and the injection are already inert — so -**click-through does not work in CMS mode today, and PR 4 shipping list-driven-only is not a regression.** -Making it work means injecting `CMS_EDITOR_SCRIPT` into the customer's production origin and trusting -messages back: a real trust-boundary expansion, an explicit decision, never a refactor side effect. If taken, -derive `previewOrigin` from the URL **currently in the iframe** (five call sites: `:876, 915, 925, 932, 954`), -`null` during swaps, never `"*"`, and add `e.source === previewIframeRef.current?.contentWindow`. - -**Authorization — a gate on the boot button.** `SANDBOX_START` sits under `basic-usage`, so any org member -can provision compute. Only matters once a boot button is put in front of editors. - ---- - -## Open questions - -1. **Do we take the injection trust-boundary expansion?** If not, canvas click-through does not work in CMS - mode and the panel is list-driven only. -2. **Which content source wins** when the gate is on and a pod is running — GitHub branch head or the - working tree? -3. **Per-org concurrent-pod cap.** None exists. Out of scope, but it should be a ticket. - -## The wall — noted, deliberately not solved here - -An editor who needs a field that does not exist cannot get it in CMS mode, and the gate is per-project -(`header-info.tsx:22`), so there is no sibling code thread to hand off to. Decision: **out of scope**, on two -grounds. - -**It is pre-existing.** Editors hit the identical wall today in the nested blocks pane. Moving the panel -neither creates nor worsens it, so it fails the scope test even though it is a real gap. - -**The responsive version is not buildable.** A message that reacts to *what the editor wanted* needs an -intent signal, and the block form has none — it renders the fields that exist and never learns which one you -wished for. Only a static, always-on note is implementable without adding a prompt box, i.e. without adding -the chat CMS mode does not have. - -If it is picked up later it is its own change, independent of the panel move and applying equally to the old -pane and the new one: a static note on the block form plus two i18n keys, optionally deep-linking to the -component via `__resolveType` (`parse-sections.ts:31`). CMS mode already ships `viewOnGithub` / -`resolveOnGithub`, so an external link is the established shape. - -## The governing pattern: branch at the mount, don't thread a mode - -PR #6054 (merged) settled this, and the code says why: - -> *"Fast Preview swaps in the CMS renderer **here, not inside `HeaderActions`**, so the sandbox hooks that -> renderer mounts (events, lifecycle, publish gate) never mount on a surface that has no sandbox."* -> — `views/virtual-mcp/header-info.tsx:11-15` - -So the rule is **not** "thread a `mode` parameter into shared components". It is: at the boundary, mount a -different component. `SelectCmsHeaderButtonInput` takes branch, PR, checks, reviews and in-flight flags — -**no pod, no lifecycle, no mode enum**. The gate stays `resolveCmsMode(metadata).active`, read at mount -points. - -This is simpler than the first draft's "re-key the gates" approach and it is already precedent. Where a -surface cannot be swapped wholesale (the preview canvas), keep the existing pure function's own rules and -change only its inputs — never invent a parallel one. - -**Consequence for rule 7.** The shipped design assumes CMS ⟹ no pod, structurally, at the mount. A CMS -project that also runs a pod is now *harder* than before #6054, not easier — see the plan's note on PR 5. - -## Already solved by #6054 - -- **The header action bar in CMS mode.** `CmsHeaderActions` + `cms-panel-state.ts` (7 states, ~90 unit - tests) replace the 21-state vibecoding machine. The five dead-click actions are gone. -- **The `send()` / `openSidePanel("chat")` collision** the first draft called "not free". CMS mode never - mounts `HeaderActions`, so there is nothing to disable. Dropped from scope. -- **`usePublishGate` and its 10s GitHub poll** are off the CMS path entirely. - -## Reusable from #6054 - -- **`isCmsStateSettling()`** and its rule — *never render a confident state from data a pending operation is - about to change; every busy flag spans its own follow-up read*. This applies directly to the mode switch - and any boot UI. -- **`SplitButton`** (`packages/ui`) — `disabled` disables only the primary half, so an inert pill can still - offer menu actions. -- **`isCheckFailed` / `isCheckInProgress`**, newly exported from `panel-state.ts`. - -## Out of scope - -- **The `Content` main-panel tab.** Stays — `ContentBrowser` browses everything; the side panel edits the - current page. -- **The sidebar thread list.** Unchanged. -- **The vibecoding header bar.** Untouched by this work. From 86c0fb40572da49da170792ad2c7b716fd5e5815 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 17 Aug 2026 17:43:54 -0300 Subject: [PATCH 08/19] feat(cms-mode): gate CMS mode per branch so both editors share a draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMS mode was a project-level either/or: `resolveCmsMode(metadata).active` short-circuited the whole sandbox proxy to `runner: null` for every branch, so a CMS project could never boot a pod and vibecoding was unreachable. Split the gate in two. `resolveCmsMode` stays the project capability; the new `resolveCmsModeForBranch` narrows it with "does this branch have a sandbox?" and is what every runtime surface now reads. Provisioning a sandbox moves that one branch onto the daemon while its siblings stay sandbox-less. That keeps exactly one writer per branch. The CMS hooks already had both substrates wired — decofile API vs `/sandbox/write`, preview server vs dev server — so following the branch means a CMS edit on a pod-backed draft lands in the working tree next to the agent's edits, instead of committing to a head the pod can no longer see. No divergence to warn about. The switch itself: the chat composer on a sandbox-less draft now offers "Start coding" (SANDBOX_START) instead of a locked input, and a CMS project with a pod shows both the CMS and Chat toggles rather than one or the other. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/api/routes/sandbox-proxy.ts | 53 +++++++++++++++-- .../api/src/tools/sandbox/sandbox-map.test.ts | 29 ++++++++++ apps/api/src/tools/sandbox/sandbox-map.ts | 18 ++++++ apps/web/src/components/chat/input.tsx | 49 +++++++++++++--- apps/web/src/components/cms-tour/cms-tour.tsx | 6 +- .../sandbox/blocks/blocks-panel.tsx | 6 +- .../sandbox/content/content-browser.tsx | 3 +- .../hooks/sandbox-lifecycle-context.tsx | 27 +++++++-- .../components/sandbox/preview/preview.tsx | 12 ++-- .../sections-editor/use-decofile.ts | 4 +- .../sections-editor/use-delete-block.ts | 4 +- .../sections-editor/use-live-meta.ts | 4 +- .../sections-editor/use-save-block.ts | 4 +- .../use-section-preview-base.ts | 14 +++-- apps/web/src/i18n/en/chat.ts | 3 +- apps/web/src/i18n/pt-br/chat.ts | 3 +- .../workspace-panel-group.tsx | 46 ++++++++------- .../main-panel-with-drawer.tsx | 7 ++- .../main-panel-tabs/use-main-panel-tabs.ts | 4 +- apps/web/src/sdk/cms-mode.ts | 1 + .../web/src/views/virtual-mcp/header-info.tsx | 11 ++-- packages/shared/src/cms-mode.test.ts | 57 +++++++++++++++++++ packages/shared/src/cms-mode.ts | 30 ++++++++++ 23 files changed, 314 insertions(+), 81 deletions(-) create mode 100644 packages/shared/src/cms-mode.test.ts diff --git a/apps/api/src/api/routes/sandbox-proxy.ts b/apps/api/src/api/routes/sandbox-proxy.ts index af6ecd4215..66eb499769 100644 --- a/apps/api/src/api/routes/sandbox-proxy.ts +++ b/apps/api/src/api/routes/sandbox-proxy.ts @@ -19,12 +19,21 @@ import { composeSandboxRef } from "@decocms/sandbox/provider"; import type { SandboxProvider } from "@decocms/sandbox/provider"; import type { ClaimPhase } from "@decocms/sandbox/provider/agent-sandbox"; import { computeClaimHandle } from "../../sandbox/claim-handle"; -import { resolveSandboxUserId } from "../../tools/sandbox/thread-repo"; +import { + getThreadSandboxMap, + resolveSandboxUserId, + threadIdFromBranch, +} from "../../tools/sandbox/thread-repo"; +import { + hasVmForBranch, + readSandboxMap, +} from "../../tools/sandbox/sandbox-map"; import { resolveSandboxProvider } from "../../sandbox/resolve-provider"; import { getUserId, requireAuth, requireOrganization, + type StudioContext, } from "../../core/studio-context"; import type { Env } from "../hono-env"; import { patchSandboxOperator } from "../../tools/sandbox/patch-sandbox-operator"; @@ -38,7 +47,10 @@ import { suggestCommitMessageWithLlm, } from "../../lib/suggest-commit-message"; import { judgeRequiresReviewWithLlm } from "../../lib/judge-requires-review"; -import { resolveCmsMode } from "@decocms/shared/cms-mode"; +import { + resolveCmsMode, + resolveCmsModeForBranch, +} from "@decocms/shared/cms-mode"; import { gitDataClientForRepo } from "../../decofile/client-for-repo"; import { GitHubApiError } from "../../decofile/github-git-data"; import { @@ -66,7 +78,7 @@ interface VmClaim { * `resolveSandboxUserId`). */ callerUserId: string; /** Null when no sandbox runner is configured on this studio instance — or - * when the project is sandbox-less (`fastPreview` below). */ + * when this branch is sandbox-less (`fastPreview` below). */ runner: SandboxProvider | null; virtualMcpId: string; branch: string; @@ -75,10 +87,13 @@ interface VmClaim { virtualMcpMetadata: Record | null; connectionIds: string[]; /** - * Sandbox-less Fast Preview project: no runner exists by design. The + * Sandbox-less branch of a CMS project: no runner exists by design. The * `/git/*` routes answer from the GitHub API (see decofile/git-compat.ts) * so the publish dialog and header work with no working tree behind them; * every other daemon-backed route stays unavailable. + * + * Per BRANCH, not per project: provisioning a sandbox for one branch moves + * that branch onto the daemon while its siblings stay sandbox-less. */ fastPreview?: boolean; } @@ -137,6 +152,30 @@ function quickFileOpSignal(c: Context): AbortSignal { ]); } +/** + * Does this branch have a dev environment recorded for it? + * + * Checks the agent row first, then — for a thread-scoped branch, whose sandbox + * records itself on the THREAD (see `setThreadSandboxMapEntry`) — the thread + * row. Missing the thread record would hand a branch that already has a pod + * back to the head-committing CMS path, giving that branch two writers. + * + * The record, not a liveness probe: a stopped or evicted pod still owns its + * branch and resumes, so the branch must not silently revert to sandbox-less. + */ +async function branchHasSandbox( + ctx: StudioContext, + metadata: Record | null, + userId: string, + branch: string, +): Promise { + if (hasVmForBranch(readSandboxMap(metadata), userId, branch)) return true; + const threadId = threadIdFromBranch(branch); + if (!threadId) return false; + const threadMap = await getThreadSandboxMap(ctx, threadId); + return hasVmForBranch(threadMap, userId, branch); +} + // ---- Shared middleware ------------------------------------------------------ /** @@ -209,7 +248,11 @@ const resolveVmClaim = createMiddleware(async (c, next) => { // Sandbox-less Fast Preview: there is no runner by design. Claim the route // with runner:null + the flag so the `/git/*` handlers serve their // GitHub-backed equivalents; daemon-backed routes 503 via requireRunner. - if (resolveCmsMode(virtualMcpMetadata).active) { + // Skipped unless the project is CMS-capable — nothing else reads the answer. + const hasSandbox = resolveCmsMode(virtualMcpMetadata).active + ? await branchHasSandbox(ctx, virtualMcpMetadata, sandboxUserId, branch) + : false; + if (resolveCmsModeForBranch(virtualMcpMetadata, hasSandbox).active) { c.set("vmClaim", { claimName, callerUserId: userId, diff --git a/apps/api/src/tools/sandbox/sandbox-map.test.ts b/apps/api/src/tools/sandbox/sandbox-map.test.ts index 8d93936b07..d8fc2c5b4d 100644 --- a/apps/api/src/tools/sandbox/sandbox-map.test.ts +++ b/apps/api/src/tools/sandbox/sandbox-map.test.ts @@ -8,6 +8,7 @@ import type { SandboxRecord } from "@decocms/shared/sdk"; import { deleteSandboxMapEntry, mergeSandboxMapEntry, + hasVmForBranch, readSandboxMap, resolveVm, } from "./sandbox-map"; @@ -268,3 +269,31 @@ describe("setSandboxMapEntry", () => { expect(sm.u?.b?.["agent-sandbox"]).toEqual(newEntry); }); }); + +describe("hasVmForBranch", () => { + const map = { + "user-1": { "branch-a": { "agent-sandbox": ENTRY_A } }, + }; + + test("true for a branch with a recorded sandbox", () => { + expect(hasVmForBranch(map, "user-1", "branch-a")).toBe(true); + }); + + /** Kind-agnostic: a sibling kind still means the branch has a pod. */ + test("true regardless of which provider kind recorded it", () => { + const desktop = { "user-1": { "branch-a": { "user-desktop": ENTRY_B } } }; + expect(hasVmForBranch(desktop, "user-1", "branch-a")).toBe(true); + }); + + test("false for an unknown user, branch, or empty map", () => { + expect(hasVmForBranch(map, "user-2", "branch-a")).toBe(false); + expect(hasVmForBranch(map, "user-1", "branch-b")).toBe(false); + expect(hasVmForBranch({}, "user-1", "branch-a")).toBe(false); + }); + + test("false for a branch cell with no kinds in it", () => { + expect( + hasVmForBranch({ "user-1": { "branch-a": {} } }, "user-1", "branch-a"), + ).toBe(false); + }); +}); diff --git a/apps/api/src/tools/sandbox/sandbox-map.ts b/apps/api/src/tools/sandbox/sandbox-map.ts index 95dbf36dee..6626142aa4 100644 --- a/apps/api/src/tools/sandbox/sandbox-map.ts +++ b/apps/api/src/tools/sandbox/sandbox-map.ts @@ -27,6 +27,24 @@ export function readSandboxMap( return raw as SandboxMap; } +/** + * Whether ANY sandbox is recorded for this (user, branch), regardless of + * provider kind — the "does this branch have a dev environment?" question. + * + * Kind-agnostic on purpose: `resolveVm` answers "which pod serves this branch + * under kind X", and a caller deciding whether the branch lives on a daemon at + * all must not miss a sibling recorded under a different kind. + */ +export function hasVmForBranch( + sandboxMap: SandboxMap, + userId: string, + branch: string, +): boolean { + const raw = sandboxMap[userId]?.[branch]; + if (!raw) return false; + return Object.keys(parseBranchMap(raw)).length > 0; +} + export function resolveVm( sandboxMap: SandboxMap, userId: string, diff --git a/apps/web/src/components/chat/input.tsx b/apps/web/src/components/chat/input.tsx index 9233fc3f69..16aa63203e 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -16,6 +16,7 @@ import { useVirtualMCP, } from "@/sdk"; import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useNavigate } from "@tanstack/react-router"; import { ArrowUp, @@ -94,6 +95,40 @@ function ChatInputDisabledState({ ); } +/** + * The composer on a sandbox-less CMS draft: the agent needs a working tree to + * edit, so this offers to provision one instead of locking the input. + * + * The door between the two editors, and it is not one-way — once the draft has + * a dev environment the CMS panel keeps working, writing through that pod + * rather than committing to the branch head (see `resolveCmsModeForBranch`). + */ +function StartCodingState() { + const t = useT(); + const { start, status } = useSandboxLifecycle(); + const starting = status !== "idle"; + return ( +
+
+ + {t("chat.input.cmsModeNoChat")} +
+ +
+ ); +} + /** * Attaches window-level dragenter/dragleave/dragover/drop listeners and * processes dropped files into the current Tiptap editor. @@ -386,7 +421,8 @@ export function ChatInput({ const { org, locator } = useProjectContext(); const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; const selectedVm = useVirtualMCP(selectedVirtualMcp?.id); - const cmsModeActive = resolveCmsMode(selectedVm?.metadata).active; + const cmsCapable = resolveCmsMode(selectedVm?.metadata).active; + const { cmsModeActive } = useSandboxLifecycle(); const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); const { unsupportedFile, onUnsupportedFile, clearUnsupportedFile } = @@ -635,12 +671,11 @@ export function ChatInput({ ); } - // Fast Preview projects are sandbox-less, and a chat run still dispatches to - // a sandbox runner — a message would hang against a runner that will never - // exist. Hold the composer with an honest notice until the agent learns to - // work through the decofile API (or per-thread sandbox fallback lands). - if (cmsModeActive) { - return ; + // A chat run dispatches to a sandbox runner, so a sandbox-less draft has + // nothing to run against. Offer to provision one rather than hold the + // composer shut — that is the switch into vibecoding. + if (cmsCapable && cmsModeActive) { + return ; } return ( diff --git a/apps/web/src/components/cms-tour/cms-tour.tsx b/apps/web/src/components/cms-tour/cms-tour.tsx index 61ac46a485..8b5aea5779 100644 --- a/apps/web/src/components/cms-tour/cms-tour.tsx +++ b/apps/web/src/components/cms-tour/cms-tour.tsx @@ -21,7 +21,7 @@ import type { Config, Driver, DriveStep } from "driver.js"; import "driver.js/dist/driver.css"; import "./cms-tour.css"; import { authClient } from "@/lib/auth-client"; -import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useVirtualMCP } from "@/sdk"; @@ -179,9 +179,9 @@ export function CmsTour({ virtualMcpId }: { virtualMcpId: string }) { const userId = session?.user?.id; const isCodeAgent = agentHasClonableSource(entity?.metadata); - // CMS mode has no dev server to wait for — its surface is up immediately. + // A sandbox-less branch has no dev server to wait for — it is up immediately. const previewReady = - resolveCmsMode(entity?.metadata).active || + useSandboxLifecycle().cmsModeActive || vmEvents.lifecycle.phase === "running"; const eligible = isCodeAgent && previewReady && !!userId; diff --git a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx index c3bbb2192d..bf770579a2 100644 --- a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx @@ -1,7 +1,6 @@ import { Suspense, lazy } from "react"; import { Loading01 } from "@untitledui/icons"; -import { useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useProjectContext } from "@/sdk"; import { useChatTask } from "@/components/chat/context"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; @@ -62,13 +61,12 @@ export function BlocksPanel({ : null; const decofile = useDecofile(fetchParams, { fetchEnabled: devServerReady }); const meta = useLiveMeta(fetchParams, { fetchEnabled: devServerReady }); - const vmcp = useVirtualMCP(virtualMcpId); const state = resolveBlocksTabState({ lifecyclePhase: sandboxEvents.lifecycle.phase, decofile: toBlocksQueryState(decofile), meta: toBlocksQueryState(meta), hasEditableContent: hasEditableDecoContent(decofile.data, meta.data), - cmsModeActive: resolveCmsMode(vmcp?.metadata).active, + cmsModeActive: lifecycle.cmsModeActive, }); if (state.kind === "loading") return ; diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index 3314efd6e6..28f9fe451b 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -64,7 +64,6 @@ import { createReferencedBlockSaver } from "@/components/sections-editor/save-re import { CollectionsSidebar } from "./collections-sidebar"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import { SandboxStateRenderer } from "./sandbox-state-renderer"; import { buildDuplicatePage, @@ -251,7 +250,6 @@ export function ContentBrowser({ mode = "content" }: ContentBrowserProps) { const { org } = useProjectContext(); const virtualMcpId = inset?.entity?.id ?? null; - const cmsModeActive = resolveCmsMode(inset?.entity?.metadata).active; const vmEvents = useSandboxEvents(); // Resolve the sandbox from the shared lifecycle context — the same source @@ -260,6 +258,7 @@ export function ContentBrowser({ mode = "content" }: ContentBrowserProps) { // that in. Reading `inset.entity.metadata.sandboxMap` directly would miss it // and strand Content on "starting" for the ephemeral Decopilot agent. const lifecycle = useSandboxLifecycle(); + const cmsModeActive = lifecycle.cmsModeActive; const previewUrl = lifecycle.previewUrl; const sandboxState = lifecycle.previewState; diff --git a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx index cabfb48a6b..4a7101a9f8 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -40,10 +40,10 @@ export interface ShouldAutoStartArgs { userStopped: boolean; isPending: boolean; attempted: boolean; - /** Fast Preview projects are sandbox-less: the CMS reads/writes GitHub - * through the decofile API and the preview renders against production, so - * arriving at a branch must NOT boot a pod. A user-driven `start()` (e.g. - * for the Code tab) still works — only the auto-start is gated. */ + /** This branch is sandbox-less: the CMS reads/writes GitHub through the + * decofile API and the preview renders against the preview server, so + * arriving at it must NOT boot a pod. A user-driven `start()` (the switch + * into vibecoding) still works — only the auto-start is gated. */ cmsModeActive: boolean; } @@ -361,7 +361,7 @@ import { useProjectContext, useVirtualMCP, } from "@/sdk"; -import { resolveCmsMode } from "@/sdk/cms-mode"; +import { resolveCmsModeForBranch } from "@/sdk/cms-mode"; import type { SandboxMap } from "@decocms/shared/sdk/types"; import { useQueryClient } from "@tanstack/react-query"; import { invalidateVirtualMcpQueries } from "@/lib/query-keys"; @@ -406,6 +406,15 @@ export interface SandboxLifecycleValue { branch: string | null; previewState: PreviewState; status: DrawerStatus; + /** + * This branch is served sandbox-lessly right now — the CMS reads and writes + * the branch head over HTTP and there is no daemon behind it. + * + * The gate every daemon-backed surface must use. It is NOT the project flag: + * a CMS project whose branch has a sandbox is `false` here, because that + * branch's reads, writes, preview and tabs all belong to the pod. + */ + cmsModeActive: boolean; vmEntry: BranchMapEntryLike | null; previewUrl: string | null; userStopped: boolean; @@ -420,6 +429,7 @@ const DEFAULT_VALUE: SandboxLifecycleValue = { branch: null, previewState: { kind: "starting" }, status: "idle", + cmsModeActive: false, vmEntry: null, previewUrl: null, userStopped: false, @@ -474,7 +484,6 @@ export function SandboxLifecycleProvider({ // ShouldAutoStartArgs.cmsModeActive). Self-heal/claim-retry stay ungated — // they only ever fire for a sandbox that already exists. const vmcp = useVirtualMCP(virtualMcpId ?? undefined); - const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; const mcpClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -540,6 +549,11 @@ export function SandboxLifecycleProvider({ // matching entry wins; with no entry for that kind (or no kind at all) fall // back to whatever is serving the branch. See resolveVmEntry. const vmEntry = resolveVmEntry(branchMap, sandboxProviderKind); + // Per branch: a recorded sandbox moves THIS branch onto the daemon. + const cmsModeActive = resolveCmsModeForBranch( + vmcp?.metadata, + !!vmEntry, + ).active; const failedPhase = events.phase?.kind === "failed" ? events.phase : null; const previewUrl = resolvePreviewUrl({ vmEntry, @@ -814,6 +828,7 @@ export function SandboxLifecycleProvider({ branch, previewState, status, + cmsModeActive, vmEntry, previewUrl, userStopped, diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx index 3f20139ab4..d95fd71884 100644 --- a/apps/web/src/components/sandbox/preview/preview.tsx +++ b/apps/web/src/components/sandbox/preview/preview.tsx @@ -358,12 +358,12 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // persisted) → the original blocking overlay is kept. const inset = useInsetContext(); // Scoped to THIS agent's entity; the shared helper owns the gate itself. - const cmsGate = - inset?.entity?.id === virtualMcpId - ? resolveCmsMode(inset.entity.metadata) - : { previewServerUrl: null, active: false }; - const previewServerUrl = cmsGate.previewServerUrl; - const cmsModeEnabled = cmsGate.active; + const shellEntity = inset?.entity?.id === virtualMcpId ? inset.entity : null; + const isShellEntity = shellEntity !== null; + const previewServerUrl = shellEntity + ? resolveCmsMode(shellEntity.metadata).previewServerUrl + : null; + const cmsModeEnabled = isShellEntity && lifecycle.cmsModeActive; // Decofile pages/global sections for the URL bar dropdown. Not gated on the // dev server: when it's down we read the committed `.deco/*.gen.json` snapshot diff --git a/apps/web/src/components/sections-editor/use-decofile.ts b/apps/web/src/components/sections-editor/use-decofile.ts index 90c3618000..36113321da 100644 --- a/apps/web/src/components/sections-editor/use-decofile.ts +++ b/apps/web/src/components/sections-editor/use-decofile.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useVirtualMCP } from "@/sdk"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; @@ -47,7 +47,7 @@ export function useDecofile( // branch head on GitHub — no dev server, no working tree. The read also // seeds KEYS.decofileDraft ({version, token}) so the preview can build its // `?__draft=` pointer before any save happens. - const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; + const cmsModeActive = useSandboxLifecycle().cmsModeActive; const queryClient = useQueryClient(); return useQuery({ queryKey: KEYS.decofile(key), diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index 60f4788a2e..22a20a66d0 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useVirtualMCP } from "@/sdk"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import { KEYS } from "@/lib/query-keys"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -36,7 +36,7 @@ export function useDeleteBlock({ const packagePath = vmcp?.metadata?.runtime?.path ?? null; // Sandbox-less mode: deletes commit through the decofile API and remove every // encoding alias of the key server-side. - const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; + const cmsModeActive = useSandboxLifecycle().cmsModeActive; return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), diff --git a/apps/web/src/components/sections-editor/use-live-meta.ts b/apps/web/src/components/sections-editor/use-live-meta.ts index 367ba2f7df..470f789e52 100644 --- a/apps/web/src/components/sections-editor/use-live-meta.ts +++ b/apps/web/src/components/sections-editor/use-live-meta.ts @@ -1,11 +1,11 @@ import { type Query, useQuery } from "@tanstack/react-query"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useVirtualMCP } from "@/sdk"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; import { KEYS } from "@/lib/query-keys"; import { decoRepoPath } from "./deco-repo-path"; import { readCommittedJson } from "./read-committed-file"; import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import type { LiveMeta } from "./resolve-schema"; interface UseLiveMetaParams { @@ -68,7 +68,7 @@ export function useLiveMeta( const virtualMcp = useVirtualMCP(params?.virtualMcpId); const packagePath = virtualMcp?.metadata?.runtime?.path ?? null; const productionUrl = resolvePreviewServerUrl(virtualMcp?.metadata); - const cmsModeActive = resolveCmsMode(virtualMcp?.metadata).active; + const cmsModeActive = useSandboxLifecycle().cmsModeActive; return useQuery({ // productionUrl is appended so a settings edit re-fetches; invalidators key // on the (org, vm, branch) prefix, which still matches (variadic key). diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index f74bdd227d..b3a56155aa 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useVirtualMCP } from "@/sdk"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import { toast } from "sonner"; import { decoBlockFilePath } from "./deco-block-key"; import { decoRepoPath } from "./deco-repo-path"; @@ -38,7 +38,7 @@ export function useSaveBlock({ // Sandbox-less mode: writes go through the decofile API (a coalesced commit // on the branch) instead of the sandbox working tree. The server owns the // key -> file mapping, so no path construction here. - const cmsModeActive = resolveCmsMode(vmcp?.metadata).active; + const cmsModeActive = useSandboxLifecycle().cmsModeActive; return useMutation({ mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), diff --git a/apps/web/src/components/sections-editor/use-section-preview-base.ts b/apps/web/src/components/sections-editor/use-section-preview-base.ts index be4062d0c3..f0dd67eb77 100644 --- a/apps/web/src/components/sections-editor/use-section-preview-base.ts +++ b/apps/web/src/components/sections-editor/use-section-preview-base.ts @@ -1,14 +1,15 @@ import { useVirtualMCP } from "@/sdk"; import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { resolveSectionPreviewBase } from "./section-preview-url"; /** * Effective base origin for the Add Section gallery previews. * - * Fast Preview ON → always the preview server; OFF → the sandbox dev server - * (see `resolveSectionPreviewBase`). Fast Preview is gated the same way - * everywhere (`resolveCmsMode`): the switch is on AND a preview server - * URL is set. + * Sandbox-less branch → the preview server; otherwise the sandbox dev server + * (see `resolveSectionPreviewBase`). Gated per branch, not per project: once a + * branch has a pod its thumbnails must come from that pod's dev server, or the + * gallery would preview the deployed site while the editor edits the sandbox. * * Returns `null` when neither base is available, so callers withhold the * gallery instead of rendering broken thumbnails. @@ -18,10 +19,11 @@ export function useSectionPreviewBase(input: { sandboxUrl: string | null | undefined; }): string | null { const vmcp = useVirtualMCP(input.virtualMcpId); - const { previewServerUrl, active } = resolveCmsMode(vmcp?.metadata); + const { previewServerUrl } = resolveCmsMode(vmcp?.metadata); + const { cmsModeActive } = useSandboxLifecycle(); return resolveSectionPreviewBase({ sandboxUrl: input.sandboxUrl, previewServerUrl, - cmsModeActive: active, + cmsModeActive, }); } diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index 77aed4ee2e..a7e699feef 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -237,7 +237,8 @@ export const chat = { "chat.input.codingAgentRequiresDesktop": "Continue this coding-agent chat in the Studio desktop app.", "chat.input.cmsModeNoChat": - "Chat isn't available in CMS mode — use the CMS panel to edit content.", + "This draft has no dev environment yet, so there's nothing for the agent to edit.", + "chat.input.startCoding": "Start coding", "chat.input.readOnlyOthersChat": "Read only - you're viewing someone else's chat", "chat.input.readOnlyOthersChatNamed": diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index 96507012a2..19b64af71f 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -244,7 +244,8 @@ export const chat = { "chat.input.codingAgentRequiresDesktop": "Continue este chat do agente de código no aplicativo Studio para desktop.", "chat.input.cmsModeNoChat": - "O chat não está disponível no modo CMS — use o painel CMS para editar o conteúdo.", + "Este rascunho ainda não tem ambiente de desenvolvimento, então não há nada para o agente editar.", + "chat.input.startCoding": "Começar a programar", "chat.input.readOnlyOthersChat": "Apenas leitura - você está visualizando um chat de outra pessoa", "chat.input.readOnlyOthersChatNamed": diff --git a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx index 578a9a1c3b..11a9f98d27 100644 --- a/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx +++ b/apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx @@ -17,6 +17,7 @@ import { } from "react"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { ResizableHandle, ResizablePanel, @@ -122,7 +123,8 @@ export function WorkspacePanelGroup({ chatContent, }: WorkspacePanelGroupProps) { // Sandbox-less: the side panel hosts the block editor, not an inert chat. - const cmsModeActive = resolveCmsMode(entity.metadata).active; + const cmsCapable = resolveCmsMode(entity.metadata).active; + const { cmsModeActive } = useSandboxLifecycle(); const [sidePanelWidth, setSidePanelWidth] = useSidePanelWidth(); const panelGroupRef = useRef(null); const visibility = { sidePanel, mainOpen }; @@ -176,22 +178,35 @@ export function WorkspacePanelGroup({ }); }, [sideSize, mainSize]); - const chatHeader = ( - - {agentCrumb} - {cmsModeActive ? ( + /** + * Which side-panel occupants this branch offers. A CMS project always offers + * the block editor; chat needs a sandbox, so a sandbox-less branch withholds + * it. Once that branch has a pod BOTH appear — the two editors share the + * branch, each writing through whichever substrate the branch is on. + */ + const sidePanelToggles = (disableActiveSidePanelToggle: boolean) => ( + <> + {cmsCapable && ( - ) : ( + )} + {!cmsModeActive && ( )} + + ); + + const chatHeader = ( + + {agentCrumb} + {sidePanelToggles(!mainOpen)} {mainControlsInChat && ( {!chatOpen && agentCrumb} - {!chatOpen && - (cmsModeActive ? ( - - ) : ( - - ))} + {!chatOpen && sidePanelToggles(false)} {chatOpen && - (cmsModeActive ? ( + (sidePanel === "cms" && cmsCapable ? ( ) : ( diff --git a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx index 5bf49e46a3..9fa1c8c523 100644 --- a/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx +++ b/apps/web/src/layouts/main-panel-tabs/main-panel-with-drawer.tsx @@ -14,7 +14,7 @@ import { useSearch } from "@tanstack/react-router"; import { useChatTask } from "@/components/chat/chat-context"; import { useInsetContext } from "@/layouts/agent-shell-layout"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; -import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { MainPanelContent } from "@/layouts/main-panel-tabs"; import { OVERLAY_TABS } from "./tab-id"; import { PreviewDrawerHost } from "./preview-drawer-host"; @@ -33,8 +33,9 @@ export function MainPanelWithDrawer({ const hasClonableSource = agentHasClonableSource(inset?.entity?.metadata) || agentHasClonableSource(activeTask?.metadata); - // CMS mode is sandbox-less — no daemon for a terminal to attach to. - const hasDaemon = !resolveCmsMode(inset?.entity?.metadata).active; + const { cmsModeActive } = useSandboxLifecycle(); + // A sandbox-less branch has no daemon for a terminal to attach to. + const hasDaemon = !cmsModeActive; const showDrawer = hasClonableSource && hasDaemon && diff --git a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index 9ec5c1a6ac..ce2f4540a1 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -43,7 +43,6 @@ import { useLiveMeta } from "@/components/sections-editor/use-live-meta"; import { hasEditableDecoContent } from "@/components/sections-editor/page-list"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; -import { resolveCmsMode } from "@/sdk/cms-mode"; import type { ThreadExpandedTool, ThreadMetadata, @@ -218,8 +217,7 @@ export function useMainPanelTabs(ctx: { // server is up (shared query keys with Preview / Content). Requires // SandboxEventsProvider (desktop tabs bar lives inside VmEventsBridge). const vmEvents = useSandboxEvents(); - const { vmEntry, previewUrl } = useSandboxLifecycle(); - const cmsModeActive = resolveCmsMode(entity?.metadata).active; + const { vmEntry, previewUrl, cmsModeActive } = useSandboxLifecycle(); // CMS mode reads the decofile over HTTP; the lifecycle never leaves "idle". const devServerReady = cmsModeActive || vmEvents.lifecycle.phase === "running"; diff --git a/apps/web/src/sdk/cms-mode.ts b/apps/web/src/sdk/cms-mode.ts index b5edbb7445..d094133192 100644 --- a/apps/web/src/sdk/cms-mode.ts +++ b/apps/web/src/sdk/cms-mode.ts @@ -9,6 +9,7 @@ export { resolveCmsMode, + resolveCmsModeForBranch, type CmsModeGate, type CmsModeMetadata, } from "@decocms/shared/cms-mode"; diff --git a/apps/web/src/views/virtual-mcp/header-info.tsx b/apps/web/src/views/virtual-mcp/header-info.tsx index 1038b7b3c9..0da3efbebe 100644 --- a/apps/web/src/views/virtual-mcp/header-info.tsx +++ b/apps/web/src/views/virtual-mcp/header-info.tsx @@ -1,6 +1,6 @@ import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { agentShowsGithubHeaderActions } from "@/lib/agent-capabilities"; -import { resolveCmsMode } from "@/sdk/cms-mode"; +import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { CmsHeaderActions } from "../../components/thread/github/cms-header-actions.tsx"; import { HeaderActions } from "../../components/thread/github/header-actions.tsx"; import { DevAgentControl } from "../../components/dev-agent/dev-agent-control.tsx"; @@ -10,16 +10,17 @@ import { OpenInBoardButton } from "../../components/thread/open-in-board-button. * The agent's header actions (dev-agent control + GitHub publish/PR buttons), * rendered inline into the main panel header's right cluster. * - * Fast Preview swaps in the CMS renderer here, not inside `HeaderActions`, so - * the sandbox hooks that renderer mounts (events, lifecycle, publish gate) - * never mount on a surface that has no sandbox. + * A sandbox-less branch swaps in the CMS renderer here, not inside + * `HeaderActions`, so the sandbox hooks that renderer mounts (events, + * lifecycle, publish gate) never mount on a surface that has no sandbox. Once + * the branch has a pod the vibecoding renderer takes over — same project. */ export function VirtualMcpHeaderInfo({ virtualMcp, }: { virtualMcp: VirtualMCPEntity; }) { - const cmsModeActive = resolveCmsMode(virtualMcp.metadata).active; + const { cmsModeActive } = useSandboxLifecycle(); return (
diff --git a/packages/shared/src/cms-mode.test.ts b/packages/shared/src/cms-mode.test.ts new file mode 100644 index 0000000000..a1cd0fc629 --- /dev/null +++ b/packages/shared/src/cms-mode.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { resolveCmsMode, resolveCmsModeForBranch } from "./cms-mode.ts"; + +const CMS_PROJECT = { + cmsMode: true, + previewServerUrl: "https://preview.example.com", +}; + +describe("resolveCmsMode", () => { + test("needs both the flag and a preview server URL", () => { + expect(resolveCmsMode(CMS_PROJECT).active).toBe(true); + expect(resolveCmsMode({ cmsMode: true }).active).toBe(false); + expect( + resolveCmsMode({ previewServerUrl: "https://preview.example.com" }) + .active, + ).toBe(false); + }); + + test("reads the legacy fastPreview flag", () => { + expect( + resolveCmsMode({ + fastPreview: true, + previewServerUrl: "https://preview.example.com", + }).active, + ).toBe(true); + }); + + test("null metadata is not CMS mode", () => { + expect(resolveCmsMode(null).active).toBe(false); + expect(resolveCmsMode(undefined).active).toBe(false); + }); +}); + +describe("resolveCmsModeForBranch", () => { + test("a CMS branch with no sandbox is sandbox-less", () => { + expect(resolveCmsModeForBranch(CMS_PROJECT, false).active).toBe(true); + }); + + /** The project flag stays on; the branch's reads/writes move to the pod. */ + test("a sandbox takes the branch off the sandbox-less path", () => { + expect(resolveCmsMode(CMS_PROJECT).active).toBe(true); + expect(resolveCmsModeForBranch(CMS_PROJECT, true).active).toBe(false); + }); + + test("a sandbox never turns a non-CMS project into one", () => { + expect(resolveCmsModeForBranch({ cmsMode: false }, false).active).toBe( + false, + ); + expect(resolveCmsModeForBranch(null, false).active).toBe(false); + }); + + test("the preview server URL survives the narrowing", () => { + expect(resolveCmsModeForBranch(CMS_PROJECT, true).previewServerUrl).toBe( + resolveCmsMode(CMS_PROJECT).previewServerUrl, + ); + }); +}); diff --git a/packages/shared/src/cms-mode.ts b/packages/shared/src/cms-mode.ts index 5c688b1781..5f75e46c58 100644 --- a/packages/shared/src/cms-mode.ts +++ b/packages/shared/src/cms-mode.ts @@ -1,6 +1,9 @@ /** * The CMS-mode gate, in ONE place — shared by the web app and the API. * + * `resolveCmsMode` is the project capability; `resolveCmsModeForBranch` narrows + * it to a single branch, and runtime surfaces gate on the latter. + * * CMS mode (formerly "Fast Preview") is the sandbox-less editing surface: the * decofile is read and written over HTTP against a preview server instead of * through the sandbox daemon, so no pod is needed. That is only possible when a @@ -30,6 +33,33 @@ export interface CmsModeGate { active: boolean; } +/** + * Whether a *branch* is being served sandbox-lessly right now. + * + * `resolveCmsMode` answers a question about the PROJECT ("can this edit content + * without a pod?"); this answers the one every runtime surface actually needs + * ("is there a pod serving this branch?"). They differ the moment a sandbox is + * provisioned: a CMS project keeps its flag, but that branch now has a working + * tree, a dev server and a daemon, and every read/write must go through them. + * + * Routing on the project flag instead would give the branch two writers — the + * CMS committing to the branch head while the pod edits an uncommitted working + * tree it can no longer see. Routing on this keeps exactly one writer per + * branch, whichever layer that branch currently lives in, so the two editing + * surfaces compose instead of diverging. + * + * `hasSandbox` is the branch's recorded sandbox (`sandboxMap[user][branch]`), + * not a liveness probe: a stopped or evicted pod is still that branch's home + * and resumes rather than handing the branch back to the head-committing path. + */ +export function resolveCmsModeForBranch( + metadata: CmsModeMetadata | null | undefined, + hasSandbox: boolean, +): CmsModeGate { + const gate = resolveCmsMode(metadata); + return { ...gate, active: gate.active && !hasSandbox }; +} + /** True when either the current or the legacy flag is set. */ function readCmsModeFlag( metadata: CmsModeMetadata | null | undefined, From 2c7c1b24bdae9200fddf6a4696a1b9dcba888731 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 18 Aug 2026 09:37:07 -0300 Subject: [PATCH 09/19] test(cms-mode): cover the per-branch gate end-to-end; fix the start-coding button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate change had no black-box coverage. Adds cms-mode-branch-gate.spec.ts, which asserts over HTTP that a CMS project's `git/status` is GitHub-backed until a sandbox is recorded for that branch, that recording one takes THAT branch off the GitHub path, and that its sibling branch is unaffected — the per-branch half of the contract, which a project-level gate would fail. Promotes the CMS-project fixture (and the GitHub stub admin helpers) out of decofile-api.spec.ts into fixtures/cms-project.ts so both suites share one definition instead of the second copying it. Two fixes found while verifying: - `StartCodingState` disabled itself on `status !== "idle"`, but the live provider never yields "idle" — `computeDrawerStatus` maps a branch with no preview URL to "starting", which is every sandbox-less branch. The button was permanently disabled. Replaced with `isStarting`, the start mutation's own pending flag, now exposed on the lifecycle context. - The per-branch signal now also counts a pending start and the seeded preview URL, so the branch leaves the sandbox-less path at the CLICK rather than when the metadata refetch lands. In that window the pod is already cloning the branch head, so a CMS write routed to the head could miss the clone and be lost silently; routed to the not-yet-reachable sandbox it fails visibly. Verified: 12/12 of both e2e specs on a clean Postgres. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/input.tsx | 12 +- .../hooks/sandbox-lifecycle-context.tsx | 25 ++- packages/e2e/fixtures/cms-project.ts | 151 ++++++++++++++++ .../e2e/tests/cms-mode-branch-gate.spec.ts | 171 ++++++++++++++++++ packages/e2e/tests/decofile-api.spec.ts | 170 ++--------------- 5 files changed, 365 insertions(+), 164 deletions(-) create mode 100644 packages/e2e/fixtures/cms-project.ts create mode 100644 packages/e2e/tests/cms-mode-branch-gate.spec.ts diff --git a/apps/web/src/components/chat/input.tsx b/apps/web/src/components/chat/input.tsx index 16aa63203e..fbdff2c93d 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -105,19 +105,17 @@ function ChatInputDisabledState({ */ function StartCodingState() { const t = useT(); - const { start, status } = useSandboxLifecycle(); - const starting = status !== "idle"; + const { start, isStarting } = useSandboxLifecycle(); return (
-
- - {t("chat.input.cmsModeNoChat")} -
+ + {t("chat.input.cmsModeNoChat")} +