Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1bee7f0
feat(cms-mode): host the CMS block editor in the side panel
tlgimenes Aug 17, 2026
a31cbe2
fix(cms-mode): hide the new-chat action in CMS mode
tlgimenes Aug 17, 2026
7e159b9
fix(cms-mode): gate the terminal on capability, not on the toggle's view
tlgimenes Aug 17, 2026
a91227b
refactor(preview): drop the terminal-visibility flag; the drawer owns…
tlgimenes Aug 17, 2026
ce45498
fix(cms-mode): stop the sandbox lifecycle gating Content and the Code…
tlgimenes Aug 17, 2026
1e3514f
fix(cms-tour): let the CMS tour run in CMS mode
tlgimenes Aug 17, 2026
8b5f6e5
chore(cms-mode): drop the design docs from the branch
tlgimenes Aug 17, 2026
86c0fb4
feat(cms-mode): gate CMS mode per branch so both editors share a draft
tlgimenes Aug 17, 2026
2c7c1b2
test(cms-mode): cover the per-branch gate end-to-end; fix the start-c…
tlgimenes Aug 18, 2026
39ea657
fix(cms-mode): make the mode switch reachable, and say which substrat…
tlgimenes Aug 18, 2026
0d8ffef
fix(preview): a CMS project renders one block editor, in the side panel
tlgimenes Aug 18, 2026
050cb07
fix(preview): drop the Edit divider when the Edit action isn't there
tlgimenes Aug 18, 2026
46e1479
Merge remote-tracking branch 'origin/main' into claude/sharded-mappin…
tlgimenes Aug 18, 2026
f4955b9
feat(cms-mode): one split button for the CMS/vibecoding switch
tlgimenes Aug 18, 2026
8dc0c2d
feat(cms-mode): use the four-square grid as the CMS mark
tlgimenes Aug 18, 2026
2a9409e
feat(cms-mode): show the mode marks in the switch's dropdown
tlgimenes Aug 18, 2026
3fd3473
style(cms-mode): outline the mode switch
tlgimenes Aug 18, 2026
4a0f444
style(cms-mode): separate the mode switch from the view tabs when col…
tlgimenes Aug 18, 2026
9773d27
feat(cms-mode): the switch changes the whole workspace, not just the …
tlgimenes Aug 18, 2026
cf8479b
Merge remote-tracking branch 'origin/main' into claude/sharded-mappin…
tlgimenes Aug 18, 2026
b2d8dd3
fix(cms-mode): remember the editing mode per thread
tlgimenes Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions apps/api/src/api/routes/decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -118,14 +116,9 @@ const resolveDecofileScope = createMiddleware<DecofileEnv>(async (c, next) => {
}
const metadata = (virtualMcp.metadata as Record<string, unknown>) ?? 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 =
Expand Down
56 changes: 48 additions & 8 deletions apps/api/src/api/routes/sandbox-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -75,10 +87,13 @@ interface VmClaim {
virtualMcpMetadata: Record<string, unknown> | 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;
}
Expand Down Expand Up @@ -137,6 +152,30 @@ function quickFileOpSignal(c: Context<VmEnv>): 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<string, unknown> | null,
userId: string,
branch: string,
): Promise<boolean> {
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 ------------------------------------------------------

/**
Expand Down Expand Up @@ -209,10 +248,11 @@ const resolveVmClaim = createMiddleware<VmEnv>(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,
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/tools/sandbox/sandbox-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SandboxRecord } from "@decocms/shared/sdk";
import {
deleteSandboxMapEntry,
mergeSandboxMapEntry,
hasVmForBranch,
readSandboxMap,
resolveVm,
} from "./sandbox-map";
Expand Down Expand Up @@ -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);
});
});
18 changes: 18 additions & 0 deletions apps/api/src/tools/sandbox/sandbox-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/chat/hooks/use-chat-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 41 additions & 10 deletions apps/web/src/components/chat/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="flex w-full flex-col gap-2.5 rounded-xl border border-border bg-muted/40 px-3 py-2.5">
<span className="text-sm text-muted-foreground">
{t("chat.input.cmsModeNoChat")}
</span>
<Button
size="sm"
variant="outline"
className="self-start"
disabled={isStarting}
onClick={() => {
track("cms_start_coding_clicked", {});
start();
}}
>
{t("chat.input.startCoding")}
</Button>
</div>
);
}

/**
* Attaches window-level dragenter/dragleave/dragover/drop listeners and
* processes dropped files into the current Tiptap editor.
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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 (
<ChatInputDisabledState message={t("chat.input.fastPreviewComingSoon")} />
);
// 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 <StartCodingState />;
}

return (
Expand Down
14 changes: 6 additions & 8 deletions apps/web/src/components/cms-tour/cms-tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) =>
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading