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..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 { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +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,10 +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 ( - virtualMcpMetadata?.fastPreview === true && - resolvePreviewServerUrl(virtualMcpMetadata) - ) { + // 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/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..fbdff2c93d 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -15,7 +15,8 @@ import { useProjectContext, useVirtualMCP, } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +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,38 @@ 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, isStarting } = useSandboxLifecycle(); + return ( +
+ + {t("chat.input.cmsModeNoChat")} + + +
+ ); +} + /** * Attaches window-level dragenter/dragleave/dragover/drop listeners and * processes dropped files into the current Tiptap editor. @@ -386,7 +419,8 @@ 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 cmsCapable = resolveCmsMode(selectedVm?.metadata).active; + const { cmsModeActive } = useSandboxLifecycle(); const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); const { unsupportedFile, onUnsupportedFile, clearUnsupportedFile } = @@ -635,14 +669,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 (fastPreviewActive) { - 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 6163de84c2..8b5aea5779 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 { 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"; @@ -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"; + // A sandbox-less branch has no dev server to wait for — it is up immediately. + const previewReady = + useSandboxLifecycle().cmsModeActive || + vmEvents.lifecycle.phase === "running"; const eligible = isCodeAgent && previewReady && !!userId; const [launched, setLaunched] = useState(false); diff --git a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx index d4d5a201df..f223b52e0b 100644 --- a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx @@ -1,7 +1,14 @@ import { Suspense, lazy } from "react"; -import { Loading01 } from "@untitledui/icons"; -import { useProjectContext, useVirtualMCP } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { AlertTriangle, Loading01 } from "@untitledui/icons"; +import { useT } from "@/i18n/use-t"; +import { useQuery } from "@tanstack/react-query"; +import { + countLocalWork, + fetchGitStatus, + hasPublishableLocalWork, + sandboxGitStatusQueryKey, +} from "@/components/thread/github/sandbox-git-api"; +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"; @@ -24,6 +31,35 @@ import { } from "@/layouts/main-panel-tabs/blocks-tab-states"; import { MainPanelLoading } from "@/layouts/main-panel-tabs/main-panel-loading"; +/** + * The dev environment holds work this view cannot see. + * + * Editing content in CMS mode commits to the branch head. When the draft also + * has a pod carrying an uncommitted working tree, that head is NOT what the + * agent is editing — the two diverge, and the agent's next commit resolves the + * conflict one way or the other. The switch permits this on purpose; the least + * it owes the user is to say so while it is true. + * + * Deliberately silent otherwise. A clean pod, or no pod, means the head is + * authoritative and there is nothing to report — and an advisory that shows on + * every CMS project is one nobody reads by the second week. + */ +function StaleHeadNotice({ count }: { count: number }) { + const t = useT(); + return ( +
+ + + {t("sandbox.blocksPanel.staleHeadNotice", { count: String(count) })} + +
+ ); +} + const SectionsEditor = lazy(() => import("@/components/sections-editor/sections-editor").then((m) => ({ default: m.SectionsEditor, @@ -46,6 +82,22 @@ export function BlocksPanel({ const { currentBranch } = useChatTask(); const sandboxEvents = useSandboxEvents(); const lifecycle = useSandboxLifecycle(); + /** + * The pod's REAL working tree. `/git/status` stays daemon-backed whenever a + * sandbox exists — the API gates on the substrate, not on `?mode=` — so this + * sees the agent's uncommitted work even while the UI is in CMS mode. That + * asymmetry is what makes the advisory possible at all. + */ + const podBranch = lifecycle.vmEntry ? (currentBranch ?? "") : ""; + const podStatus = useQuery({ + queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, podBranch), + queryFn: () => fetchGitStatus(org.slug, virtualMcpId, podBranch), + enabled: !!podBranch, + staleTime: 15_000, + }); + const staleCount = hasPublishableLocalWork(podStatus.data) + ? countLocalWork(podStatus.data) + : 0; const workspace = useBlocksPreviewWorkspace(); const devServerReady = sandboxEvents.lifecycle.phase === "running"; const previewUrl = lifecycle.previewUrl; @@ -62,13 +114,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), - fastPreviewActive: resolveFastPreview(vmcp?.metadata).active, + cmsModeActive: lifecycle.cmsModeActive, }); if (state.kind === "loading") return ; @@ -136,7 +187,11 @@ export function BlocksPanel({ : `path:${currentPath}`; return ( -
+
+ {staleCount > 0 && } diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index 01309f2993..28f9fe451b 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -258,10 +258,12 @@ 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; - 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 ( (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..a9e23fb297 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -40,11 +40,11 @@ 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. */ - fastPreviewActive: boolean; + /** 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; } /** @@ -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,8 @@ import { useProjectContext, useVirtualMCP, } from "@/sdk"; -import { resolveFastPreview } from "@/sdk/fast-preview"; +import { resolveCmsModeForBranch, type CmsEditingMode } from "@/sdk/cms-mode"; +import { useSearch } from "@tanstack/react-router"; import type { SandboxMap } from "@decocms/shared/sdk/types"; import { useQueryClient } from "@tanstack/react-query"; import { invalidateVirtualMcpQueries } from "@/lib/query-keys"; @@ -406,6 +407,19 @@ 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; + /** A SANDBOX_START issued from this provider is in flight. NOT derived from + * `status`, which reads "starting" for any branch with no preview URL — + * including a sandbox-less one that was never asked to boot. */ + isStarting: boolean; vmEntry: BranchMapEntryLike | null; previewUrl: string | null; userStopped: boolean; @@ -420,6 +434,8 @@ const DEFAULT_VALUE: SandboxLifecycleValue = { branch: null, previewState: { kind: "starting" }, status: "idle", + cmsModeActive: false, + isStarting: false, vmEntry: null, previewUrl: null, userStopped: false, @@ -471,10 +487,13 @@ 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; + /** `?mode=` — which way the user is editing this draft. Only consulted once + * the branch HAS a sandbox; before that CMS is the only possibility. */ + const editingMode = + (useSearch({ strict: false }) as { mode?: CmsEditingMode }).mode ?? "cms"; const mcpClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -553,6 +572,21 @@ export function SandboxLifecycleProvider({ seeded: failedPhase ? null : seededPreviewUrl, key: sandboxPreviewKey(virtualMcpId, branch), }); + /** + * Per branch: a sandbox moves THIS branch onto the daemon. + * + * `isPending` and the seeded `previewUrl` count alongside the recorded entry + * so the branch flips at the CLICK, not 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 silently lost; routed to the + * (not yet reachable) sandbox it fails visibly instead, which is the honest + * half of the trade. + */ + const cmsModeActive = resolveCmsModeForBranch( + vmcp?.metadata, + !!vmEntry || !!previewUrl || startVm.isPending, + editingMode, + ).active; const userStopped = !!virtualMcpId && !!branch && @@ -601,7 +635,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(() => { @@ -814,6 +848,8 @@ export function SandboxLifecycleProvider({ branch, previewState, status, + cmsModeActive, + isStarting: startVm.isPending, vmEntry, previewUrl, userStopped, diff --git a/apps/web/src/components/sandbox/preview/editing-mode.test.ts b/apps/web/src/components/sandbox/preview/editing-mode.test.ts index 14ff3d4838..43ad7707c6 100644 --- a/apps/web/src/components/sandbox/preview/editing-mode.test.ts +++ b/apps/web/src/components/sandbox/preview/editing-mode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { shouldAutoOpenCms, togglePreviewEditorMode } from "./editing-mode"; +import { + resolveEffectiveEditingMode, + shouldAutoOpenCms, + togglePreviewEditorMode, +} from "./editing-mode"; describe("togglePreviewEditorMode", () => { test("activates an editor from the neutral preview", () => { @@ -47,3 +51,62 @@ describe("shouldAutoOpenCms", () => { expect(shouldAutoOpenCms({ ...ready, editingMode: "visual" })).toBe(false); }); }); + +describe("resolveEffectiveEditingMode", () => { + const base = { sandboxDisplay: true, cmsCapable: false } as const; + + test("passes through when nothing blocks the request", () => { + for (const mode of ["preview", "visual", "blocks"] as const) { + expect(resolveEffectiveEditingMode({ ...base, editingMode: mode })).toBe( + mode, + ); + } + }); + + test("visual falls back to preview without the sandbox iframe", () => { + expect( + resolveEffectiveEditingMode({ + ...base, + editingMode: "visual", + sandboxDisplay: false, + }), + ).toBe("preview"); + }); + + /** The side panel owns block editing there; the inline pane would duplicate it. */ + test("blocks never opens inline on a CMS project", () => { + expect( + resolveEffectiveEditingMode({ + ...base, + editingMode: "blocks", + cmsCapable: true, + }), + ).toBe("preview"); + }); + + /** + * Project-level, not per-branch: a CMS draft that gains a sandbox must not + * resurrect the inline pane alongside the side panel's copy. + */ + test("a CMS project keeps blocks out inline in both modes", () => { + for (const sandboxDisplay of [true, false]) { + expect( + resolveEffectiveEditingMode({ + editingMode: "blocks", + sandboxDisplay, + cmsCapable: true, + }), + ).toBe("preview"); + } + }); + + test("a non-CMS project keeps its inline blocks pane", () => { + expect( + resolveEffectiveEditingMode({ + editingMode: "blocks", + sandboxDisplay: false, + cmsCapable: false, + }), + ).toBe("blocks"); + }); +}); diff --git a/apps/web/src/components/sandbox/preview/editing-mode.ts b/apps/web/src/components/sandbox/preview/editing-mode.ts index 453f40a665..0756ccfe92 100644 --- a/apps/web/src/components/sandbox/preview/editing-mode.ts +++ b/apps/web/src/components/sandbox/preview/editing-mode.ts @@ -31,3 +31,33 @@ export function shouldAutoOpenCms(input: { input.editingMode === "preview" ); } + +/** + * The editing mode the preview can actually honour right now. + * + * Two requests get downgraded to plain preview: + * - `visual` without the live sandbox iframe — the production fallback is a + * different origin we cannot inject into. + * - `blocks` on a CMS project — its block editor lives in the SIDE PANEL, so + * the inline pane would be a second copy of the same editor with its own + * selection state. + * + * `cmsCapable` is deliberately the project-level gate, not the per-branch one: + * the side panel owns content editing in both CMS and vibecoding mode, so + * provisioning a sandbox must not resurrect the inline pane. Projects with no + * CMS keep it as their only way in. + * + * `blocks` otherwise survives a sandbox restart, so its loading/error state + * stays actionable and the panel keeps reading the committed snapshot. + */ +export function resolveEffectiveEditingMode(input: { + editingMode: PreviewEditingMode; + /** The preview is showing the live sandbox iframe (not a fallback origin). */ + sandboxDisplay: boolean; + /** The project has a CMS, so the side panel owns block editing. */ + cmsCapable: boolean; +}): PreviewEditingMode { + if (input.editingMode === "visual" && !input.sandboxDisplay) return "preview"; + if (input.editingMode === "blocks" && input.cmsCapable) return "preview"; + return input.editingMode; +} 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..27d09ab523 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"; @@ -22,22 +22,21 @@ import { Code01, Compass01, Copy01, + CreditCardSearch, CursorClick01, Database01, DotsHorizontal, Globe02, + Grid01, LayoutAlt01, LinkExternal01, Loading01, - Plus, - PuzzlePiece01, - SearchLg, - CreditCardSearch, Monitor04, Phone02, + Plus, RefreshCw01, + SearchLg, 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, @@ -85,7 +83,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 { @@ -154,6 +152,7 @@ import { import { shouldAutoOpenCms, togglePreviewEditorMode, + resolveEffectiveEditingMode, type PreviewEditingMode, type PreviewEditorMode, } from "./editing-mode"; @@ -244,7 +243,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({ @@ -360,18 +358,22 @@ 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 = - 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; + // Scoped to THIS agent's entity; the shared helper owns the gate itself. + const shellEntity = inset?.entity?.id === virtualMcpId ? inset.entity : null; + const isShellEntity = shellEntity !== null; + const cmsGate = shellEntity ? resolveCmsMode(shellEntity.metadata) : null; + const previewServerUrl = cmsGate?.previewServerUrl ?? null; + /** + * The PROJECT has a CMS, so its block editor lives in the side panel and this + * toolbar's Edit action + nested pane must not exist — they would be a second + * copy of the same `BlocksPanel`, carrying its own selection state. + * + * Project-level, not per-branch: the side panel owns content editing in BOTH + * modes, so provisioning a sandbox must not resurrect the inline pane. + * Projects with no CMS keep that pane as their only way in. + */ + const cmsCapable = cmsGate?.active ?? false; + 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 @@ -399,7 +401,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 +553,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 +572,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 +599,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,14 +864,11 @@ 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. - const effectiveEditingMode: PreviewEditingMode = - display.mode !== "sandbox" && editingMode === "visual" - ? "preview" - : editingMode; + const effectiveEditingMode: PreviewEditingMode = resolveEffectiveEditingMode({ + editingMode, + sandboxDisplay: display.mode === "sandbox", + cmsCapable, + }); // oxlint-disable-next-line ban-use-effect/ban-use-effect — DOM event subscription useEffect(() => { @@ -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 && !cmsCapable; + const cmsToggle = showCmsToggle ? ( toggleEditingMode("blocks")} testId="preview-blocks-toggle" @@ -1582,127 +1582,105 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { const urlControls = showPreviewToolbar ? (
{cmsToggle} -
+ {cmsToggle &&
} {urlGroup}
) : 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 = fastPreviewEnabled ? null : terminal; - 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 ? ( @@ -1839,23 +1817,25 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { orientation="horizontal" disabled={effectiveEditingMode !== "blocks"} > - - {effectiveEditingMode === "blocks" && ( - - )} - - {effectiveEditingMode === "blocks" && ( + {!cmsCapable && ( + + {effectiveEditingMode === "blocks" && ( + + )} + + )} + {!cmsCapable && 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..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 { resolveFastPreview } from "@/sdk/fast-preview"; 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 = useSandboxLifecycle().cmsModeActive; 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 527f4b8138..a3fa22c656 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 { resolveFastPreview } from "@/sdk/fast-preview"; 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 = useSandboxLifecycle().cmsModeActive; 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..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 { resolveFastPreview } from "@/sdk/fast-preview"; 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 = 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). @@ -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 5027f9b7aa..f34ab63d67 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 { resolveFastPreview } from "@/sdk/fast-preview"; 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 = useSandboxLifecycle().cmsModeActive; 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..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 { resolveFastPreview } from "@/sdk/fast-preview"; +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 (`resolveFastPreview`): 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 } = resolveFastPreview(vmcp?.metadata); + const { previewServerUrl } = resolveCmsMode(vmcp?.metadata); + const { cmsModeActive } = useSandboxLifecycle(); return resolveSectionPreviewBase({ sandboxUrl: input.sandboxUrl, previewServerUrl, - fastPreviewActive: active, + cmsModeActive, }); } 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/components/thread/github/sandbox-git-api.test.ts b/apps/web/src/components/thread/github/sandbox-git-api.test.ts index feffe0e012..3c89b9c43a 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.test.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_PUBLISH_POLICY, hasGitLocalWork, hasLocalWorkToPush, + countLocalWork, hasPublishableLocalWork, hasUnpublishedWork, isDecoOnlyDiff, @@ -555,3 +556,46 @@ describe("combinePublishDiffs (full publish payload = committed ∪ working)", ( expect(gate.reason).toBeNull(); }); }); + +describe("countLocalWork", () => { + const clean: GitStatus = { + not_added: [], + conflicted: [], + created: [], + deleted: [], + modified: [], + renamed: [], + files: [], + staged: [], + ahead: 0, + behind: 0, + current: "main", + tracking: null, + detached: false, + }; + + test("counts nothing for a clean tree or no status", () => { + expect(countLocalWork(clean)).toBe(0); + expect(countLocalWork(null)).toBe(0); + }); + + /** A path lands in several git buckets at once; counting it twice would + * overstate the divergence the advisory reports. */ + test("dedupes a path that is both staged and modified", () => { + expect( + countLocalWork({ ...clean, staged: ["a.ts"], modified: ["a.ts"] }), + ).toBe(1); + }); + + test("ignores generated artifacts, like the predicate does", () => { + const status = { ...clean, modified: [".deco/blocks.gen.json", "a.ts"] }; + expect(countLocalWork(status)).toBe(1); + expect(hasPublishableLocalWork(status)).toBe(true); + }); + + test("agrees with the predicate on an artifact-only tree", () => { + const status = { ...clean, modified: [".deco/blocks.gen.json"] }; + expect(countLocalWork(status)).toBe(0); + expect(hasPublishableLocalWork(status)).toBe(false); + }); +}); diff --git a/apps/web/src/components/thread/github/sandbox-git-api.ts b/apps/web/src/components/thread/github/sandbox-git-api.ts index 7842940cb1..f420eef802 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.ts @@ -235,19 +235,41 @@ function isGeneratedArtifactPath(path: string): boolean { ); } -/** Uncommitted work that would actually change the site. */ -export function hasPublishableLocalWork( - status: GitStatus | null | undefined, -): boolean { - if (!status) return false; - if (status.conflicted.length > 0 || status.renamed.length > 0) return true; +/** + * Paths of uncommitted work that would actually change the site. + * + * The single list behind both {@link hasPublishableLocalWork} and + * {@link countLocalWork}: a predicate and a count derived separately could + * disagree, and "3 unsaved changes" next to a banner that shouldn't be showing + * is worse than either alone. + */ +function localWorkPaths(status: GitStatus | null | undefined): string[] { + if (!status) return []; return [ ...status.modified, ...status.created, ...status.deleted, ...status.not_added, ...status.staged, - ].some((path) => !isGeneratedArtifactPath(path)); + ].filter((path) => !isGeneratedArtifactPath(path)); +} + +/** Uncommitted work that would actually change the site. */ +export function hasPublishableLocalWork( + status: GitStatus | null | undefined, +): boolean { + if (!status) return false; + if (status.conflicted.length > 0 || status.renamed.length > 0) return true; + return localWorkPaths(status).length > 0; +} + +/** + * How many distinct files that work touches. Deduped: a path can appear in + * several of git's buckets at once (staged AND modified is the common one), and + * counting it twice overstates the divergence the user is being warned about. + */ +export function countLocalWork(status: GitStatus | null | undefined): number { + return new Set(localWorkPaths(status)).size; } /** 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/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/agent-shell-layout.ts b/apps/web/src/i18n/en/agent-shell-layout.ts index 6678994d5e..7c955796d8 100644 --- a/apps/web/src/i18n/en/agent-shell-layout.ts +++ b/apps/web/src/i18n/en/agent-shell-layout.ts @@ -17,11 +17,20 @@ export const agentShellLayout = { "agentShellLayout.agentShellLayout.taskUnavailable": "Task unavailable", "agentShellLayout.libraryToggle.library": "Library", "agentShellLayout.tasksToggle.tasks": "Tasks", + "agentShellLayout.toggleButtons.cms": "CMS", "agentShellLayout.toggleButtons.hideChat": "Hide chat", "agentShellLayout.toggleButtons.hidePanel": "Hide panel", "agentShellLayout.toggleButtons.showChat": "Show chat", "agentShellLayout.toggleButtons.showPanel": "Show panel", "agentShellLayout.toggleButtons.chat": "Chat", + "agentShellLayout.toggleButtons.chooseMode": "Choose mode", + "agentShellLayout.toggleButtons.cmsDescription": "Block editor", + "agentShellLayout.toggleButtons.startVibecoding": "Start vibecoding", + "agentShellLayout.toggleButtons.startVibecodingDescription": + "Builds a dev environment · about a minute", + "agentShellLayout.toggleButtons.vibecoding": "Vibecoding", + "agentShellLayout.toggleButtons.vibecodingDescription": + "Agent and dev environment", "agentShellLayout.toolbar.backToHome": "Back to home", "agentShellLayout.toolbar.logo": "Logo", } as const; diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index 8aae837074..df035e2d25 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -236,8 +236,9 @@ 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": + "This draft has no dev environment yet, so there's nothing for the agent to edit.", + "chat.input.startCoding": "Start vibecoding", "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 959aa8162b..de0b3cd1e6 100644 --- a/apps/web/src/i18n/en/sandbox.ts +++ b/apps/web/src/i18n/en/sandbox.ts @@ -1,4 +1,6 @@ export const sandbox = { + "sandbox.blocksPanel.staleHeadNotice": + "The dev environment has {count} unsaved change(s) this view can't see. Content you edit here saves separately and may be overwritten when the agent saves.", "sandbox.appEditor.createdSection": 'Created section "{name}"', "sandbox.appEditor.editingBreadcrumb": "Editing breadcrumb", "sandbox.appEditor.failedAddSection": "Could not add section", @@ -393,8 +395,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", @@ -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/en/settings.ts b/apps/web/src/i18n/en/settings.ts index be89be0ade..51418fc9df 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/agent-shell-layout.ts b/apps/web/src/i18n/pt-br/agent-shell-layout.ts index b6b1fde444..47318944e4 100644 --- a/apps/web/src/i18n/pt-br/agent-shell-layout.ts +++ b/apps/web/src/i18n/pt-br/agent-shell-layout.ts @@ -20,11 +20,20 @@ export const agentShellLayout = { "agentShellLayout.agentShellLayout.taskUnavailable": "Tarefa indisponível", "agentShellLayout.libraryToggle.library": "Biblioteca", "agentShellLayout.tasksToggle.tasks": "Tarefas", + "agentShellLayout.toggleButtons.cms": "CMS", "agentShellLayout.toggleButtons.hideChat": "Ocultar chat", "agentShellLayout.toggleButtons.hidePanel": "Ocultar painel", "agentShellLayout.toggleButtons.showChat": "Mostrar chat", "agentShellLayout.toggleButtons.showPanel": "Mostrar painel", "agentShellLayout.toggleButtons.chat": "Chat", + "agentShellLayout.toggleButtons.chooseMode": "Escolher modo", + "agentShellLayout.toggleButtons.cmsDescription": "Editor de blocos", + "agentShellLayout.toggleButtons.startVibecoding": "Começar vibecoding", + "agentShellLayout.toggleButtons.startVibecodingDescription": + "Cria um ambiente de desenvolvimento · cerca de um minuto", + "agentShellLayout.toggleButtons.vibecoding": "Vibecoding", + "agentShellLayout.toggleButtons.vibecodingDescription": + "Agente e ambiente de desenvolvimento", "agentShellLayout.toolbar.backToHome": "Voltar para home", "agentShellLayout.toolbar.logo": "Logo", } satisfies Record; diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index 2d3babe36d..935b3b25ac 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -243,8 +243,9 @@ 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": + "Este rascunho ainda não tem ambiente de desenvolvimento, então não há nada para o agente editar.", + "chat.input.startCoding": "Começar vibecoding", "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..d1d0e03155 100644 --- a/apps/web/src/i18n/pt-br/sandbox.ts +++ b/apps/web/src/i18n/pt-br/sandbox.ts @@ -1,6 +1,8 @@ import type { sandbox as sandboxEn } from "../en/sandbox.ts"; export const sandbox = { + "sandbox.blocksPanel.staleHeadNotice": + "O ambiente de desenvolvimento tem {count} alteração(ões) não salva(s) que esta visão não enxerga. O conteúdo editado aqui é salvo separadamente e pode ser sobrescrito quando o agente salvar.", "sandbox.appEditor.createdSection": 'Seção "{name}" criada', "sandbox.appEditor.editingBreadcrumb": "Trilha de navegação de edição", "sandbox.appEditor.failedAddSection": "Não foi possível adicionar a seção", @@ -408,8 +410,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": @@ -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/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 056d30fc51..a1c4241a63 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/agent-shell-layout/index.tsx b/apps/web/src/layouts/agent-shell-layout/index.tsx index 5912492f34..44d2833462 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"; @@ -506,6 +507,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/side-panel-toggles.test.ts b/apps/web/src/layouts/agent-shell-layout/side-panel-toggles.test.ts new file mode 100644 index 0000000000..fb29553e38 --- /dev/null +++ b/apps/web/src/layouts/agent-shell-layout/side-panel-toggles.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { resolveSidePanelToggles } from "./side-panel-toggles"; + +describe("resolveSidePanelToggles", () => { + test("a non-CMS project keeps the single Chat toggle", () => { + expect( + resolveSidePanelToggles({ + cmsCapable: false, + cmsModeActive: false, + navV2: false, + }), + ).toEqual({ mode: false, chat: true, startsDevEnvironment: false }); + }); + + test("a sandbox-less CMS draft offers the mode control, provisioning", () => { + expect( + resolveSidePanelToggles({ + cmsCapable: true, + cmsModeActive: true, + navV2: false, + }), + ).toEqual({ mode: true, chat: false, startsDevEnvironment: true }); + }); + + test("a CMS draft with a pod offers the mode control, just opening", () => { + expect( + resolveSidePanelToggles({ + cmsCapable: true, + cmsModeActive: false, + navV2: false, + }), + ).toEqual({ mode: true, chat: false, startsDevEnvironment: false }); + }); + + /** + * The regression this rule exists for: gating a half on the branch state hid + * the Code toggle on exactly the drafts that needed it, leaving the switch + * reachable only by hand-editing `?sidepanel=`. + */ + test("provisioning a sandbox never adds or removes a control", () => { + const before = resolveSidePanelToggles({ + cmsCapable: true, + cmsModeActive: true, + navV2: false, + }); + const after = resolveSidePanelToggles({ + cmsCapable: true, + cmsModeActive: false, + navV2: false, + }); + expect({ mode: after.mode, chat: after.chat }).toEqual({ + mode: before.mode, + chat: before.chat, + }); + }); +}); + +describe("resolveSidePanelToggles under the first-class navigation", () => { + /** Its collapse control already owns hide/show, so the bare Chat toggle goes. */ + test("a non-CMS project drops the Chat toggle", () => { + expect( + resolveSidePanelToggles({ + cmsCapable: false, + cmsModeActive: false, + navV2: true, + }).chat, + ).toBe(false); + }); + + /** Collapse answers WHETHER; the pair answers WHICH. Dropping it would leave + * a CMS project unable to choose a surface at all. */ + test("a CMS project keeps the mode control", () => { + const set = resolveSidePanelToggles({ + cmsCapable: true, + cmsModeActive: true, + navV2: true, + }); + expect(set.mode).toBe(true); + expect(set.startsDevEnvironment).toBe(true); + }); +}); diff --git a/apps/web/src/layouts/agent-shell-layout/side-panel-toggles.ts b/apps/web/src/layouts/agent-shell-layout/side-panel-toggles.ts new file mode 100644 index 0000000000..73ee6b46fd --- /dev/null +++ b/apps/web/src/layouts/agent-shell-layout/side-panel-toggles.ts @@ -0,0 +1,40 @@ +export interface SidePanelToggleSet { + /** The CMS/vibecoding split button — one control for both modes. */ + mode: boolean; + chat: boolean; + /** The Code toggle has to provision a dev environment before it can open. */ + startsDevEnvironment: boolean; +} + +/** + * Which side-panel toggles the shell renders. + * + * A CMS project always shows the mode control, in every branch state — it IS + * the switch, so withholding it strands the user wherever they are with no way + * across. That is not a style preference: a sandbox-less draft without it has + * no reachable path to vibecoding at all, and a pod-backed draft has none back + * to content. + * + * Only the control's BEHAVIOUR varies with the branch — on a sandbox-less draft + * choosing vibecoding provisions before opening. Everything that isn't a CMS + * project keeps the single Chat toggle it has always had. + * + * The pair is orthogonal to the first-class navigation's collapse control: + * that answers WHETHER the side panel shows, this answers WHICH surface fills + * it. So `navV2` retires only the bare Chat toggle, whose sole job was + * hide/show — never the mode pair, which would leave a CMS project unable to + * choose. + */ +export function resolveSidePanelToggles(args: { + /** The project can edit content without a sandbox (`resolveCmsMode`). */ + cmsCapable: boolean; + /** THIS branch is sandbox-less (`resolveCmsModeForBranch`). */ + cmsModeActive: boolean; + /** First-class navigation: a collapse control owns panel hide/show. */ + navV2: boolean; +}): SidePanelToggleSet { + if (!args.cmsCapable) { + return { mode: false, chat: !args.navV2, startsDevEnvironment: false }; + } + return { mode: true, chat: false, startsDevEnvironment: args.cmsModeActive }; +} 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 9cd4025c7c..f90b51f6f5 100644 --- a/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx +++ b/apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx @@ -1,6 +1,8 @@ import { AlignLeft01, AlignRight01, + Code01, + Grid01, LayoutRight, MessageCircle01, } from "@untitledui/icons"; @@ -10,9 +12,13 @@ import { TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; import { ToolbarIconButton } from "@/components/toolbar-icon-button"; +import { SplitButton } from "@decocms/ui/components/split-button.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import type { CmsEditingMode } from "@/sdk/cms-mode"; 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 { @@ -25,6 +31,28 @@ export interface ChatToggleProps { disableActiveSidePanelToggle?: boolean; } +export interface ModeSplitButtonProps { + /** The panel's occupant, or null while it is closed. */ + sidePanel: SidePanelKind | null; + /** The occupant the body toggles — the live one, or the remembered one while + * the panel is closed (`?sidepanel=0` keeps no kind). */ + mode: SidePanelKind; + toggleSidePanel: (sidePanel: SidePanelKind) => void; + /** Opens without the toggle's close-on-same-kind behaviour: picking the mode + * you are already in must not collapse the panel. */ + openSidePanel: (sidePanel: SidePanelKind) => void; + /** Records the choice in `?mode=`. The mode governs the whole workspace — + * preview origin, view tabs, console — so picking one has to outlive the + * side panel that revealed it. */ + setEditingMode: (mode: CmsEditingMode) => void; + /** The draft has no dev environment yet, so choosing vibecoding provisions it. */ + needsDevEnvironment: boolean; + /** Provision it. Called before the panel opens; safe to call while pending. */ + onStart: () => void; + disableActiveSidePanelToggle?: boolean; + className?: string; +} + /** * Chat toggle — opens / closes the chat side panel. Rendered through the shared * HeaderTabButton so it stays pixel-identical to the Main panel tabs. It lives @@ -57,6 +85,100 @@ export function ChatToggle({ ); } +/** + * The mode control — one split button standing in for the CMS/vibecoding pair. + * + * body → collapse or reopen the side panel, on the mode it already shows + * caret → choose the mode + * + * That division is deliberate. Collapsing is a many-times-a-session action and + * gets the wide target; choosing a mode happens about once per draft and sits + * behind a caret, which is the only thing a caret has ever meant. The earlier + * arrangement — two tab buttons — read as two features rather than two modes of + * one draft, and had nowhere to say that vibecoding must first be provisioned. + * + * `mode` is passed in rather than read off `sidePanel`, which goes null when the + * panel closes: the body has to keep toggling the occupant the user left. + */ +export function ModeSplitButton({ + sidePanel, + mode, + toggleSidePanel, + openSidePanel, + setEditingMode, + needsDevEnvironment, + onStart, + disableActiveSidePanelToggle = false, + className, +}: ModeSplitButtonProps) { + const t = useT(); + const isCms = mode === "cms"; + const pick = (kind: SidePanelKind) => { + track("agent_toolbar_mode_picked", { mode: kind }); + // Provision before opening: the panel it reveals is the agent composer, + // which stays a "start vibecoding" prompt until the branch has a pod. + if (kind === "chat" && needsDevEnvironment) onStart(); + setEditingMode(kind === "cms" ? "cms" : "vibecoding"); + openSidePanel(kind); + }; + + return ( + + ) : ( + + ) + } + disabled={disableActiveSidePanelToggle && sidePanel === mode} + onClick={() => { + track("agent_toolbar_toggled", { + button: mode === "cms" ? "cms" : "vibecoding", + next_state: sidePanel === mode ? "closed" : "open", + }); + toggleSidePanel(mode); + }} + menuAriaLabel={t("agentShellLayout.toggleButtons.chooseMode")} + dataTour={TOUR_ANCHORS.edit} + className={cn("h-10 md:h-7", className)} + items={[ + { + key: "cms", + icon: , + label: t("agentShellLayout.toggleButtons.cms"), + description: t("agentShellLayout.toggleButtons.cmsDescription"), + selected: isCms, + onSelect: () => pick("cms"), + }, + { + key: "chat", + icon: , + label: t( + needsDevEnvironment + ? "agentShellLayout.toggleButtons.startVibecoding" + : "agentShellLayout.toggleButtons.vibecoding", + ), + description: t( + needsDevEnvironment + ? "agentShellLayout.toggleButtons.startVibecodingDescription" + : "agentShellLayout.toggleButtons.vibecodingDescription", + ), + selected: !isCms, + onSelect: () => pick("chat"), + }, + ]} + /> + ); +} + /** * A panel collapse control — the pair of chevron-into-bar buttons that bracket * the workspace under the first-class navigation: the left one lives at the 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 e76fe68b86..7a830283ac 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 @@ -18,11 +18,13 @@ import { useEffect, useRef, + useState, type PropsWithChildren, 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 { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { ResizableHandle, ResizablePanel, @@ -51,10 +53,17 @@ import { useSidebar } from "@decocms/ui/components/sidebar.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; import { ThreadsMenu } from "@/components/chat/threads-menu"; import { useNavV2 } from "@/hooks/use-organization-settings"; +import { usePanelActions } from "@/layouts/shell-layout"; +import { useNavigate } from "@tanstack/react-router"; +import type { CmsEditingMode } from "@/sdk/cms-mode"; import { SidePanel } from "./side-panel"; -import { ChatToggle, PanelCollapseToggle } from "./toggle-buttons"; -import { MessageCircle01 } from "@untitledui/icons"; -import { useT } from "@/i18n/use-t"; +import { + ChatToggle, + ModeSplitButton, + PanelCollapseToggle, +} from "./toggle-buttons"; +import { resolveSidePanelToggles } from "./side-panel-toggles"; +import { BlocksPanel } from "@/components/sandbox/blocks/blocks-panel"; import { MainPanelHeaderEndSlot, MainPanelHeaderProvider, @@ -62,23 +71,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"; @@ -173,12 +165,35 @@ export function WorkspacePanelGroup({ toggleMain, 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 cmsCapable = resolveCmsMode(entity.metadata).active; + const { cmsModeActive, start: startDevEnvironment } = useSandboxLifecycle(); + const { openSidePanel } = usePanelActions(); + const navigate = useNavigate(); + /** `?mode=` is workspace state, not panel state: it survives the panel + * closing and a reload. Replaces rather than pushes — switching modes is + * not a navigation the back button should undo one step at a time. */ + const setEditingMode = (mode: CmsEditingMode) => { + void navigate({ + to: ".", + search: (prev: Record) => ({ ...prev, mode }), + replace: true, + }); + }; + /** + * The panel's occupant while it is CLOSED. `?sidepanel=0` keeps no kind, so + * without this the split button's body would have nothing to reopen — it has + * to return the user to the mode they left. Seeded from the branch, since a + * sandbox-less draft can only be CMS. Held here, not in the button, because + * the button unmounts and remounts as it relocates between the two headers. + */ + const [rememberedKind, setRememberedKind] = useState( + cmsModeActive ? "cms" : "chat", + ); + if (sidePanel !== null && sidePanel !== rememberedKind) { + setRememberedKind(sidePanel); + } + const sidePanelMode = sidePanel ?? rememberedKind; const [sidePanelWidth, setSidePanelWidth] = useSidePanelWidth(); const panelGroupRef = useRef(null); const visibility = { sidePanel, mainOpen }; @@ -209,7 +224,9 @@ export function WorkspacePanelGroup({ * the threads menu and new-chat action show whatever the sidebar's state. */ const navV2 = useNavV2(); const agentCrumb = sidebarCollapsed && !navV2 ? : null; - const newChatCrumb = sidebarCollapsed || navV2 ? : null; + // A sandbox-less CMS draft has no chat to start, in either nav. + const newChatCrumb = + (sidebarCollapsed || navV2) && !cmsModeActive ? : null; const threadsMenu = navV2 ? : null; /** @@ -240,18 +257,45 @@ export function WorkspacePanelGroup({ }); }, [sideSize, mainSize]); - const chatHeader = ( - - {threadsMenu} - {agentCrumb} - {/* The collapse pair below already owns hide/show for both panels. */} - {!navV2 && ( + // One control on a CMS project — see resolveSidePanelToggles. + const toggles = resolveSidePanelToggles({ + cmsCapable, + cmsModeActive, + navV2, + }); + const sidePanelToggles = ( + disableActiveSidePanelToggle: boolean, + modeClassName?: string, + ) => ( + <> + {toggles.mode && ( + + )} + {toggles.chat && ( )} + + ); + + const chatHeader = ( + + {threadsMenu} + {agentCrumb} + {sidePanelToggles(!mainOpen)} {mainControlsInChat && ( {!chatOpen && agentCrumb} - {navV2 ? ( + {navV2 && ( toggleSidePanel("chat")} /> - ) : ( - !chatOpen && ( - - ) )} + {!chatOpen && sidePanelToggles(false, "me-1.5")} {chatOpen && - (fastPreviewActive ? ( - + (sidePanel === "cms" && cmsCapable ? ( + ) : ( ))} 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/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 0db8586ce6..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 @@ -1,29 +1,23 @@ /** * 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"; import { useChatTask } from "@/components/chat/chat-context"; import { useInsetContext } from "@/layouts/agent-shell-layout"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; +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"; -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, @@ -35,22 +29,24 @@ 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); + const { cmsModeActive } = useSandboxLifecycle(); + // A sandbox-less branch has no daemon for a terminal to attach to. + const hasDaemon = !cmsModeActive; const showDrawer = - hasClonableSource && !(typeof main === "string" && OVERLAY_TABS.has(main)); + hasClonableSource && + hasDaemon && + !(typeof main === "string" && OVERLAY_TABS.has(main)); return ( - -
-
- -
- {showDrawer && } +
+
+
- + {showDrawer && } +
); } diff --git a/apps/web/src/layouts/main-panel-tabs/source-system-tabs.test.ts b/apps/web/src/layouts/main-panel-tabs/source-system-tabs.test.ts index f6c95e8773..02cd65ec79 100644 --- a/apps/web/src/layouts/main-panel-tabs/source-system-tabs.test.ts +++ b/apps/web/src/layouts/main-panel-tabs/source-system-tabs.test.ts @@ -15,6 +15,16 @@ describe("getSourceSystemTabs", () => { 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/terminal-visibility.tsx b/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx deleted file mode 100644 index a47c0fad35..0000000000 --- a/apps/web/src/layouts/main-panel-tabs/terminal-visibility.tsx +++ /dev/null @@ -1,86 +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 { - visible: boolean; - setVisible: (visible: boolean) => void; -} - -const TerminalVisibilityContext = createContext( - null, -); - -export function TerminalVisibilityProvider({ - virtualMcpId, - children, -}: { - virtualMcpId: string | null; - 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) => { - 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/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index ddebe100f8..40d57a9612 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 @@ -218,8 +218,10 @@ 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 devServerReady = vmEvents.lifecycle.phase === "running"; + const { vmEntry, previewUrl, cmsModeActive } = useSandboxLifecycle(); + // 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 @@ -354,7 +356,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" diff --git a/apps/web/src/layouts/resolve-task-switch-search.test.ts b/apps/web/src/layouts/resolve-task-switch-search.test.ts index 1b4336a4e3..a1542a29a8 100644 --- a/apps/web/src/layouts/resolve-task-switch-search.test.ts +++ b/apps/web/src/layouts/resolve-task-switch-search.test.ts @@ -142,3 +142,27 @@ describe("resolveTaskSwitchSearch — restoring per-thread memory", () => { }); }); }); + +describe("resolveTaskSwitchSearch — editing mode", () => { + test("restores the remembered mode with the rest of the layout", () => { + const next = resolveTaskSwitchSearch({ + prev: {}, + decopilotId: "dec_1", + savedLayout: { main: "preview", sidepanel: "cms", mode: "vibecoding" }, + autosendValue: "1", + }); + expect(next.mode).toBe("vibecoding"); + }); + + /** No memory means "use the default", which the gate reads as CMS — not a + * stale mode carried over from whichever thread we came from. */ + test("omits mode when the target thread has none remembered", () => { + const next = resolveTaskSwitchSearch({ + prev: {}, + decopilotId: "dec_1", + savedLayout: { main: "preview" }, + autosendValue: "1", + }); + expect(next.mode).toBeUndefined(); + }); +}); diff --git a/apps/web/src/layouts/resolve-task-switch-search.ts b/apps/web/src/layouts/resolve-task-switch-search.ts index 9ee9432526..c8ebe23f76 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. @@ -68,6 +69,7 @@ export function resolveTaskSwitchSearch( // stale, MainPanelContent falls back to Settings rather than crashing. if (savedLayout.main !== undefined) next.main = savedLayout.main; if (savedLayout.sidepanel !== undefined) sidepanel = savedLayout.sidepanel; + if (savedLayout.mode !== undefined) next.mode = savedLayout.mode; } else if (!isAgentSwitch) { const prevMain = prev.main; if (prevMain && typeof prevMain === "string" && !isPerThreadTab(prevMain)) { diff --git a/apps/web/src/layouts/shell-layout.tsx b/apps/web/src/layouts/shell-layout.tsx index dc58077dda..5d51151b71 100644 --- a/apps/web/src/layouts/shell-layout.tsx +++ b/apps/web/src/layouts/shell-layout.tsx @@ -1,4 +1,5 @@ import { Suspense, useEffect, useState } from "react"; +import type { CmsEditingMode } from "@/sdk/cms-mode"; import { OrgAccessGate } from "@/components/org-access-gate"; import { SplashScreen } from "@/components/splash-screen"; import { FloatingReleaseCard } from "@/components/release-channel/floating-release-card"; @@ -10,6 +11,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 +96,8 @@ export function usePanelActions() { const search = useSearch({ strict: false }) as { virtualmcpid?: string; main?: string | 0; - sidepanel?: "chat" | 0; + sidepanel?: SidePanelKind | 0; + mode?: CmsEditingMode; }; const orgSlug = params.org ?? ""; const currentTaskId = params.taskId ?? ""; @@ -116,7 +119,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 = ( @@ -131,6 +134,7 @@ export function usePanelActions() { saveThreadLayout(currentTaskId, { main: search.main, sidepanel: search.sidepanel, + mode: search.mode, }); } // Restore the target thread's own remembered layout (null when unseen this diff --git a/apps/web/src/lib/thread-layout-memory.test.ts b/apps/web/src/lib/thread-layout-memory.test.ts index ad72828389..1083cc1fae 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" }); @@ -75,3 +84,34 @@ describe("upsertThreadLayoutEntries", () => { expect(out.map(([id]) => id)).toEqual(["b", "c"]); }); }); + +describe("sanitizeThreadLayout — editing mode", () => { + test("keeps both modes", () => { + expect(sanitizeThreadLayout({ mode: "cms" }).mode).toBe("cms"); + expect(sanitizeThreadLayout({ mode: "vibecoding" }).mode).toBe( + "vibecoding", + ); + }); + + /** Storage is tamperable, and an unknown mode must read as "no memory" + * rather than reach the gate that decides the preview's origin. */ + test("drops anything that is not a known mode", () => { + expect( + sanitizeThreadLayout({ mode: "nonsense" as never }).mode, + ).toBeUndefined(); + expect(sanitizeThreadLayout({}).mode).toBeUndefined(); + }); + + test("survives a round trip alongside the panel state", () => { + const layout = sanitizeThreadLayout({ + main: "preview", + sidepanel: "cms", + mode: "vibecoding", + }); + expect(layout).toEqual({ + main: "preview", + sidepanel: "cms", + mode: "vibecoding", + }); + }); +}); diff --git a/apps/web/src/lib/thread-layout-memory.ts b/apps/web/src/lib/thread-layout-memory.ts index 473c8038c8..4470dd8265 100644 --- a/apps/web/src/lib/thread-layout-memory.ts +++ b/apps/web/src/lib/thread-layout-memory.ts @@ -15,6 +15,12 @@ * write is wrapped. A read failure means "no memory", never a crash. */ +import { + parseSidePanelKind, + type SidePanelKind, +} from "@/hooks/use-layout-state"; +import type { CmsEditingMode } from "@/sdk/cms-mode"; + const STORAGE_KEY = "studio:thread-layout:v1"; /** LRU cap. Bounds growth within a session; oldest threads evict first. */ @@ -23,8 +29,12 @@ 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; + /** `?mode` value. Remembered per thread because it outranks the panel: it + * decides the preview's origin, the view tabs and the console, so a thread + * returned to in vibecoding must not silently reopen in CMS. */ + mode?: CmsEditingMode; } /** Most-recent entry last, so `.shift()` evicts the least-recently-saved. */ @@ -40,8 +50,14 @@ 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; + } + if (layout.mode === "cms" || layout.mode === "vibecoding") { + clean.mode = layout.mode; } return clean; } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index e0876ed521..5f3453647c 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -295,7 +295,13 @@ 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(), + /** Which editing mode the draft is in. Governs the whole workspace — preview + * origin, view tabs, console — so it is its own param rather than something + * read off `sidepanel`, which forgets the mode whenever the panel closes. */ + mode: z.union([z.literal("cms"), z.literal("vibecoding")]).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..f0cc30ae9e --- /dev/null +++ b/apps/web/src/sdk/cms-mode.ts @@ -0,0 +1,16 @@ +/** + * 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, + resolveCmsModeForBranch, + type CmsEditingMode, + 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/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, - })); - }} - /> - } - /> {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/fixtures/cms-project.ts b/packages/e2e/fixtures/cms-project.ts new file mode 100644 index 0000000000..0af9eb0755 --- /dev/null +++ b/packages/e2e/fixtures/cms-project.ts @@ -0,0 +1,151 @@ +/** + * Shared wiring for a CMS-mode ("Fast Preview") project, plus the GitHub Git + * Data stub admin calls its specs need. + * + * Promoted out of decofile-api.spec.ts once a second suite needed it. Every + * GitHub call lands on the local stub (fixtures/github-stub.ts, wired via + * GITHUB_API_BASE_URL in the Playwright config) — nothing reaches api.github.com. + */ + +import type { APIRequestContext } from "@playwright/test"; +import { expect } from "./test"; +import { callSelfMcpTool, createHttpConnection } from "./mcp-tools"; + +const GITHUB_STUB_ORIGIN = `http://localhost:${process.env.GITHUB_STUB_PORT ?? "4102"}`; + +export interface StubRepoInspection { + defaultBranch: string; + mergeMode: string; + refs: Record; + commits: Array<{ sha: string; message: string; parents: string[] }>; + branches: Record }>; +} + +export async function seedStubRepo( + ctx: APIRequestContext, + params: { + owner: string; + repo: string; + defaultBranch?: string; + branches?: Record } | null>; + mergeMode?: "merge" | "conflict" | "blocked"; + }, +): Promise { + const res = await ctx.post(`${GITHUB_STUB_ORIGIN}/__admin/repos`, { + data: params, + }); + expect(res.ok()).toBe(true); +} + +export async function inspectStubRepo( + ctx: APIRequestContext, + owner: string, + repo: string, +): Promise { + const res = await ctx.get( + `${GITHUB_STUB_ORIGIN}/__admin/repos/${owner}/${repo}`, + ); + expect(res.ok()).toBe(true); + return (await res.json()) as StubRepoInspection; +} + +/** Unique owner per test run keeps the stub's repo namespace parallel-safe. */ +export const uniqueOwner = (): string => + `e2e-${crypto.randomUUID().slice(0, 12)}`; + +export interface CmsProject { + org: string; + owner: string; + repo: string; + vmcpId: string; + childConnectionId: string; +} + +/** + * Full CMS-mode project wiring: repo-scoped GitHub child + unexpired + * downstream token + a virtual MCP carrying the CMS-mode gate. + */ +export async function createCmsProject( + ctx: APIRequestContext, + org: string, + params: { + owner: string; + repo: string; + repoScopeMode?: "refreshable" | "legacy-mint"; + }, +): Promise { + const { owner, repo, repoScopeMode = "refreshable" } = params; + + const sourceConnectionId = + repoScopeMode === "legacy-mint" + ? ( + await createHttpConnection(ctx, org, { + title: `Org GitHub ${Date.now()}`, + url: "https://example.com/mcp", + }) + ).id + : undefined; + + const child = await callSelfMcpTool<{ item: { id: string } }>( + ctx, + org, + "COLLECTION_CONNECTIONS_CREATE", + { + data: { + title: `GitHub: ${owner}/${repo}`, + app_name: "mcp-github", + connection_type: "HTTP", + connection_url: "https://example.com/mcp", + metadata: { + repoScope: { + ...(sourceConnectionId ? { sourceConnectionId } : {}), + installationId: 1, + repositoryId: 99, + owner, + repo, + permissions: { contents: "write" }, + }, + }, + }, + }, + ); + const childConnectionId = child.item.id; + expect(childConnectionId).toBeTruthy(); + + // Unexpired token: read back directly, or short-circuits the legacy mint. + const tokenRes = await ctx.post( + `/api/${org}/connections/${childConnectionId}/oauth-token`, + { + data: { accessToken: "ghs_e2e_dummy", expiresIn: 3600 }, + headers: { "Content-Type": "application/json" }, + }, + ); + expect(tokenRes.ok()).toBe(true); + + const vmcp = await callSelfMcpTool<{ item: { id: string } }>( + ctx, + org, + "COLLECTION_VIRTUAL_MCP_CREATE", + { + data: { + title: `${repo} ${Date.now()}`, + metadata: { + fastPreview: true, + previewServerUrl: `https://${repo}.example.com`, + githubRepo: { + owner, + name: repo, + url: `https://github.com/${owner}/${repo}`, + installationId: 1, + connectionId: childConnectionId, + }, + }, + connections: [{ connection_id: childConnectionId }], + }, + }, + ); + const vmcpId = vmcp.item.id; + expect(vmcpId).toBeTruthy(); + + return { org, owner, repo, vmcpId, childConnectionId }; +} diff --git a/packages/e2e/tests/cms-mode-branch-gate.spec.ts b/packages/e2e/tests/cms-mode-branch-gate.spec.ts new file mode 100644 index 0000000000..2c735d6af3 --- /dev/null +++ b/packages/e2e/tests/cms-mode-branch-gate.spec.ts @@ -0,0 +1,171 @@ +/** + * The CMS-mode gate is per BRANCH, not per project. + * + * A CMS project starts sandbox-less: `/api/:org/sandbox/:vmcpId/:branch/git/*` + * is answered from the GitHub API so the header and publish dialog work with no + * working tree behind them. Recording a sandbox for one branch moves THAT branch + * onto the daemon — its siblings stay sandbox-less. + * + * Why it matters: gating on the project instead gave a branch two writers, the + * CMS committing to the branch head while a pod edited an uncommitted working + * tree it could no longer see. It also made vibecoding unreachable on a CMS + * project at all, since the proxy claimed every branch with `runner: null`. + * + * Contract shapes are INLINED (black-box suite — no app imports): + * - GitHub-backed `git/status` → 200 `{ current, base, headSha, aheadOfBase, + * behindBase, modified: [], ... }`, i.e. a clean tree it does not have. + * - Daemon-backed `git/status` → never that: with no sandbox runner reachable + * in this environment the request fails instead. + */ + +import { signUpViaApi } from "../fixtures/auth-api"; +import { callSelfMcpTool } from "../fixtures/mcp-tools"; +import { + createCmsProject, + seedStubRepo, + uniqueOwner, +} from "../fixtures/cms-project"; +import { expect, newApiContext, test } from "../fixtures/test"; + +interface GitStatusBody { + current?: string | null; + base?: string; + headSha?: string; + modified?: string[]; + aheadOfBase?: number; + behindBase?: number; +} + +const statusUrl = (org: string, vmcpId: string, branch: string): string => + `/api/${org}/sandbox/${encodeURIComponent(vmcpId)}/${encodeURIComponent(branch)}/git/status`; + +/** + * Record a sandbox for one branch the way provisioning does — a + * `sandboxMap[userId][branch][kind]` cell on the virtual MCP's metadata. + * Written through the collection tool (whose metadata update shallow-merges), + * so this stays a wire-level fixture with no app imports. + */ +async function recordSandboxForBranch( + ctx: Parameters[0], + org: string, + vmcpId: string, + userId: string, + branch: string, +): Promise { + await callSelfMcpTool(ctx, org, "COLLECTION_VIRTUAL_MCP_UPDATE", { + id: vmcpId, + data: { + metadata: { + sandboxMap: { + [userId]: { + [branch]: { + "agent-sandbox": { + sandboxHandle: "vm-e2e-1", + previewUrl: "https://vm-e2e-1.example.com", + sandboxProviderKind: "agent-sandbox", + }, + }, + }, + }, + }, + }, + }); +} + +test.describe("CMS mode is gated per branch", () => { + test("a sandbox on one branch moves only that branch off the GitHub-backed path", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const user = await signUpViaApi(ctx); + const owner = uniqueOwner(); + await seedStubRepo(ctx, { + owner, + repo: "site", + defaultBranch: "main", + branches: { + main: { files: { ".deco/blocks/hero.json": "{}\n" } }, + "draft-a": { files: { ".deco/blocks/hero.json": "{}\n" } }, + "draft-b": { files: { ".deco/blocks/hero.json": "{}\n" } }, + }, + }); + const project = await createCmsProject(ctx, user.orgSlug, { + owner, + repo: "site", + }); + + // Both drafts start sandbox-less: GitHub answers, and reports the clean + // tree of a project that has none. + for (const branch of ["draft-a", "draft-b"]) { + const res = await ctx.get( + statusUrl(user.orgSlug, project.vmcpId, branch), + ); + expect( + res.ok(), + `${branch} should be GitHub-backed before any sandbox exists`, + ).toBe(true); + const body = (await res.json()) as GitStatusBody; + expect(body.headSha, `${branch} headSha`).toBeTruthy(); + expect(body.base).toBe("main"); + expect(body.modified ?? []).toEqual([]); + } + + await recordSandboxForBranch( + ctx, + user.orgSlug, + project.vmcpId, + user.userId, + "draft-a", + ); + + // draft-a now belongs to the daemon. No runner is reachable here, so the + // request fails rather than quietly returning GitHub's view of the branch + // — which is the bug this gate exists to prevent. + const claimed = await ctx.get( + statusUrl(user.orgSlug, project.vmcpId, "draft-a"), + ); + expect( + claimed.ok(), + "draft-a must leave the GitHub-backed path once it has a sandbox", + ).toBe(false); + + // The sibling is untouched: this is the per-branch half of the contract. + const sibling = await ctx.get( + statusUrl(user.orgSlug, project.vmcpId, "draft-b"), + ); + expect( + sibling.ok(), + "draft-b has no sandbox and must stay GitHub-backed", + ).toBe(true); + const siblingBody = (await sibling.json()) as GitStatusBody; + expect(siblingBody.base).toBe("main"); + expect(siblingBody.modified ?? []).toEqual([]); + } finally { + await ctx.dispose(); + } + }); + + test("a project without the CMS gate is never GitHub-backed", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const user = await signUpViaApi(ctx); + const plain = await callSelfMcpTool<{ item: { id: string } }>( + ctx, + user.orgSlug, + "COLLECTION_VIRTUAL_MCP_CREATE", + { + data: { title: `plain ${Date.now()}`, metadata: {}, connections: [] }, + }, + ); + const res = await ctx.get( + statusUrl(user.orgSlug, plain.item.id, "some-branch"), + ); + expect(res.ok()).toBe(false); + } finally { + await ctx.dispose(); + } + }); +}); diff --git a/packages/e2e/tests/decofile-api.spec.ts b/packages/e2e/tests/decofile-api.spec.ts index 1e05222508..512eda05ee 100644 --- a/packages/e2e/tests/decofile-api.spec.ts +++ b/packages/e2e/tests/decofile-api.spec.ts @@ -26,11 +26,16 @@ import { randomUUID } from "node:crypto"; import type { APIRequestContext } from "@playwright/test"; import { signUpViaApi } from "../fixtures/auth-api"; -import { callSelfMcpTool, createHttpConnection } from "../fixtures/mcp-tools"; +import { callSelfMcpTool } from "../fixtures/mcp-tools"; +import { + createCmsProject, + inspectStubRepo, + seedStubRepo, + uniqueOwner, + type CmsProject, +} from "../fixtures/cms-project"; import { expect, newApiContext, test } from "../fixtures/test"; -const GITHUB_STUB_ORIGIN = `http://localhost:${process.env.GITHUB_STUB_PORT ?? "4102"}`; - /** Serialization the decofile writer uses for a block file. */ const blockFileContent = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; @@ -42,146 +47,7 @@ interface DecofileGetBody { decofile: Record; } -interface StubRepoInspection { - defaultBranch: string; - mergeMode: string; - refs: Record; - commits: Array<{ sha: string; message: string; parents: string[] }>; - branches: Record }>; -} - -async function seedStubRepo( - ctx: APIRequestContext, - params: { - owner: string; - repo: string; - defaultBranch?: string; - branches?: Record } | null>; - mergeMode?: "merge" | "conflict" | "blocked"; - }, -): Promise { - const res = await ctx.post(`${GITHUB_STUB_ORIGIN}/__admin/repos`, { - data: params, - }); - expect(res.ok()).toBe(true); -} - -async function inspectStubRepo( - ctx: APIRequestContext, - owner: string, - repo: string, -): Promise { - const res = await ctx.get( - `${GITHUB_STUB_ORIGIN}/__admin/repos/${owner}/${repo}`, - ); - expect(res.ok()).toBe(true); - return (await res.json()) as StubRepoInspection; -} - -interface FastPreviewProject { - org: string; - owner: string; - repo: string; - vmcpId: string; - childConnectionId: string; -} - -/** - * Full Fast Preview project wiring: repo-scoped GitHub child + unexpired - * downstream token + a virtual MCP carrying the Fast Preview gate. - * `repoScopeMode` picks which real repo-child shape to seed; the two resolve - * credentials down different paths (see `client-for-repo`), and the default is - * the one every repo imported since refreshable grants landed actually has. - */ -async function createFastPreviewProject( - ctx: APIRequestContext, - org: string, - params: { - owner: string; - repo: string; - repoScopeMode?: "refreshable" | "legacy-mint"; - }, -): Promise { - const { owner, repo, repoScopeMode = "refreshable" } = params; - - const sourceConnectionId = - repoScopeMode === "legacy-mint" - ? ( - await createHttpConnection(ctx, org, { - title: `Org GitHub ${Date.now()}`, - url: "https://example.com/mcp", - }) - ).id - : undefined; - - const child = await callSelfMcpTool<{ item: { id: string } }>( - ctx, - org, - "COLLECTION_CONNECTIONS_CREATE", - { - data: { - title: `GitHub: ${owner}/${repo}`, - app_name: "mcp-github", - connection_type: "HTTP", - connection_url: "https://example.com/mcp", - metadata: { - repoScope: { - ...(sourceConnectionId ? { sourceConnectionId } : {}), - installationId: 1, - repositoryId: 99, - owner, - repo, - permissions: { contents: "write" }, - }, - }, - }, - }, - ); - const childConnectionId = child.item.id; - expect(childConnectionId).toBeTruthy(); - - // Unexpired token: read back directly, or short-circuits the legacy mint. - const tokenRes = await ctx.post( - `/api/${org}/connections/${childConnectionId}/oauth-token`, - { - data: { accessToken: "ghs_e2e_dummy", expiresIn: 3600 }, - headers: { "Content-Type": "application/json" }, - }, - ); - expect(tokenRes.ok()).toBe(true); - - const vmcp = await callSelfMcpTool<{ item: { id: string } }>( - ctx, - org, - "COLLECTION_VIRTUAL_MCP_CREATE", - { - data: { - title: `${repo} ${Date.now()}`, - metadata: { - fastPreview: true, - previewServerUrl: `https://${repo}.example.com`, - githubRepo: { - owner, - name: repo, - url: `https://github.com/${owner}/${repo}`, - installationId: 1, - connectionId: childConnectionId, - }, - }, - connections: [{ connection_id: childConnectionId }], - }, - }, - ); - const vmcpId = vmcp.item.id; - expect(vmcpId).toBeTruthy(); - - return { org, owner, repo, vmcpId, childConnectionId }; -} - -/** Unique owner per test run keeps the stub's repo namespace parallel-safe. */ -const uniqueOwner = (): string => `e2e-${randomUUID().slice(0, 12)}`; - -const decofileUrl = (p: FastPreviewProject, branch: string): string => +const decofileUrl = (p: CmsProject, branch: string): string => `/api/${p.org}/decofile/${p.vmcpId}/${branch}`; test.describe("decofile API", () => { @@ -192,7 +58,7 @@ test.describe("decofile API", () => { try { const user = await signUpViaApi(ctx); const owner = uniqueOwner(); - const project = await createFastPreviewProject(ctx, user.orgSlug, { + const project = await createCmsProject(ctx, user.orgSlug, { owner, repo: "site", }); @@ -262,7 +128,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 +175,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(); @@ -354,7 +244,7 @@ test.describe("decofile API", () => { }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const url = decofileUrl(project, "main"); const res = await ctx.get(url); @@ -444,7 +334,7 @@ test.describe("decofile API", () => { }, }); - const project = await createFastPreviewProject(ctx, user.orgSlug, { + const project = await createCmsProject(ctx, user.orgSlug, { owner, repo, repoScopeMode: "legacy-mint", @@ -490,7 +380,7 @@ test.describe("decofile API", () => { defaultBranch: "main", branches: { main: { files } }, }); - const project = await createFastPreviewProject(ctx, user.orgSlug, { + const project = await createCmsProject(ctx, user.orgSlug, { owner, repo, }); @@ -527,7 +417,7 @@ test.describe("decofile API", () => { draft: null, }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const url = decofileUrl(project, "draft"); const before = (await (await ctx.get(url)).json()) as DecofileGetBody; @@ -613,7 +503,7 @@ test.describe("decofile API", () => { }, }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const url = decofileUrl(project, "draft"); const commitsBefore = (await inspectStubRepo(ctx, owner, repo)).commits @@ -676,7 +566,7 @@ test.describe("decofile API", () => { repo, branches: { main: { files: {} } }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const url = decofileUrl(project, "main"); const traversal = await ctx.patch(url, { @@ -741,7 +631,7 @@ test.describe("decofile API", () => { }, }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const url = decofileUrl(project, "draft"); const publishRes = await ctx.post(`${url}/publish`); @@ -788,7 +678,7 @@ test.describe("decofile API", () => { }, }, }); - const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const project = await createCmsProject(ctx, org, { owner, repo }); const publishRes = await ctx.post( `${decofileUrl(project, "draft")}/publish`, diff --git a/packages/shared/src/cms-mode.test.ts b/packages/shared/src/cms-mode.test.ts new file mode 100644 index 0000000000..5cab9be994 --- /dev/null +++ b/packages/shared/src/cms-mode.test.ts @@ -0,0 +1,72 @@ +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 branch with no sandbox is CMS whichever mode is asked for", () => { + expect(resolveCmsModeForBranch(CMS_PROJECT, false, "cms").active).toBe( + true, + ); + // Nothing to vibecode against yet, so the mode cannot override the fact. + expect( + resolveCmsModeForBranch(CMS_PROJECT, false, "vibecoding").active, + ).toBe(true); + }); + + test("with a sandbox, the mode decides", () => { + expect(resolveCmsModeForBranch(CMS_PROJECT, true, "cms").active).toBe(true); + expect( + resolveCmsModeForBranch(CMS_PROJECT, true, "vibecoding").active, + ).toBe(false); + }); + + /** Switching back restores the CMS workspace, not just the side panel. */ + test("picking CMS on a pod-backed branch returns the CMS gate", () => { + const vibe = resolveCmsModeForBranch(CMS_PROJECT, true, "vibecoding"); + const back = resolveCmsModeForBranch(CMS_PROJECT, true, "cms"); + expect(vibe.active).toBe(false); + expect(back.active).toBe(true); + }); + + test("a sandbox never turns a non-CMS project into one", () => { + expect( + resolveCmsModeForBranch({ cmsMode: false }, false, "cms").active, + ).toBe(false); + expect(resolveCmsModeForBranch(null, true, "cms").active).toBe(false); + }); + + test("the preview server URL survives the narrowing", () => { + expect( + resolveCmsModeForBranch(CMS_PROJECT, true, "vibecoding").previewServerUrl, + ).toBe(resolveCmsMode(CMS_PROJECT).previewServerUrl); + }); +}); diff --git a/packages/shared/src/cms-mode.ts b/packages/shared/src/cms-mode.ts new file mode 100644 index 0000000000..3b19d8813c --- /dev/null +++ b/packages/shared/src/cms-mode.ts @@ -0,0 +1,80 @@ +/** + * 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 + * 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), + }; +} + +/** The editing mode a draft is in. Persisted in the URL as `?mode=`. */ +export type CmsEditingMode = "cms" | "vibecoding"; + +/** + * Whether a *branch* is being served the CMS way right now — decofile over + * HTTP against the preview server, rather than through a pod. + * + * Two inputs, and they are not the same question. `hasSandbox` says what the + * branch HAS; `mode` says which way the user is currently editing it. A branch + * with no sandbox has no choice and is always CMS. A branch WITH one follows + * the mode, so switching back to CMS restores the whole CMS workspace — + * preview origin, tabs, console — and not just the side panel. + * + * The cost of honouring the mode over the substrate: a CMS write then commits + * to the branch head while the pod still holds an uncommitted working tree it + * cannot see. That divergence is real and deliberate — surfaced to the user by + * the staleness advisory rather than prevented — so anything reading this to + * decide where a WRITE lands must also be prepared to say so. + */ +export function resolveCmsModeForBranch( + metadata: CmsModeMetadata | null | undefined, + hasSandbox: boolean, + mode: CmsEditingMode = "cms", +): CmsModeGate { + const gate = resolveCmsMode(metadata); + const cmsSelected = !hasSandbox || mode === "cms"; + return { ...gate, active: gate.active && cmsSelected }; +} 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() diff --git a/packages/ui/src/components/split-button.tsx b/packages/ui/src/components/split-button.tsx index ada8e0cc2b..6ec2e7c17c 100644 --- a/packages/ui/src/components/split-button.tsx +++ b/packages/ui/src/components/split-button.tsx @@ -1,7 +1,7 @@ "use client"; import type * as React from "react"; -import { ChevronDown } from "@untitledui/icons"; +import { Check, ChevronDown } from "@untitledui/icons"; import { cn } from "../lib/utils.ts"; import { Button } from "./button.tsx"; @@ -26,6 +26,13 @@ export interface SplitButtonMenuItem { tooltip?: string; /** Rendered before the label. */ icon?: React.ReactNode; + /** Secondary line under the label, for items that need a consequence spelled + * out rather than guessed at. Stacks the entry; omit for a plain row. */ + description?: React.ReactNode; + /** Marks the item as the current choice — a check occupies a reserved gutter. + * Set it on EVERY item of a group, not just the active one, so the labels + * stay aligned whichever is selected. */ + selected?: boolean; } export interface SplitButtonProps { @@ -50,18 +57,48 @@ export interface SplitButtonProps { /** Accessible name for the chevron trigger. Required: this package is i18n-free. */ menuAriaLabel: string; className?: string; + /** `data-tour` anchor for product tours, set on the control's outer group so + * a highlight covers both halves. The package owns no tour names. */ + dataTour?: string; } function SplitButtonMenuEntry({ item }: { item: SplitButtonMenuItem }) { + const marksSelection = item.selected !== undefined; const entry = ( { item.onSelect(); }} + className={cn(item.description && "items-start")} > - {item.icon} - {item.label} + {/* A description stacks the row, so the leading marks align to its first + line rather than to the block's centre. */} + {marksSelection ? ( + + ) : null} + {item.icon ? ( + + {item.icon} + + ) : null} + {item.description ? ( + + {item.label} + + {item.description} + + + ) : ( + item.label + )} ); @@ -97,6 +134,7 @@ export function SplitButton({ items, menuAriaLabel, className, + dataTour, }: SplitButtonProps) { const hasMenu = (items?.length ?? 0) > 0; @@ -118,6 +156,7 @@ export function SplitButton({ return (