diff --git a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx
index a09336a597..dce375cbcf 100644
--- a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx
+++ b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx
@@ -22,7 +22,12 @@ import {
BlocksEmptyState,
BlocksErrorState,
} from "@/layouts/main-panel-tabs/blocks-tab-states";
+import { useContentBackend } from "@/components/sections-editor/use-content-backend";
+import { isProtocolProject } from "@/components/sections-editor/content-backend";
import { PanelLoading } from "@/layouts/main-panel-boundary";
+import { isNoSchemaMeta } from "@/components/sections-editor/schemaless";
+import { SchemalessEditor } from "@/components/sandbox/content/schemaless-editor";
+import { useNewBlocksEditorState } from "@/hooks/use-new-blocks-editor";
const SectionsEditor = lazy(() =>
import("@/components/sections-editor/sections-editor").then((m) => ({
@@ -67,6 +72,7 @@ export function BlocksPanel({
previewUrl,
}
: null;
+ const contentBackend = useContentBackend(virtualMcpId, currentBranch);
const decofile = useDecofile(fetchParams, { fetchEnabled: devServerReady });
const meta = useLiveMeta(fetchParams, { fetchEnabled: devServerReady });
const state = resolveBlocksTabState({
@@ -74,16 +80,41 @@ export function BlocksPanel({
decofile: toBlocksQueryState(decofile),
meta: toBlocksQueryState(meta),
hasEditableContent: hasEditableDecoContent(decofile.data, meta.data),
- fastPreviewActive: useSessionRuntime(virtualMcpId).runtime === "cms",
+ fastPreviewActive:
+ useSessionRuntime(virtualMcpId).runtime === "cms" ||
+ isProtocolProject(contentBackend),
});
+ // Which editor this site gets (v8: the new one) waits on the same
+ // detection, so neither editor shows and then swaps to the other.
+ const editorUndecided = useNewBlocksEditorState() === undefined;
+
const panel = (children: ReactNode) => (
{children}
);
- if (state.kind === "loading") return panel();
+ if (state.kind === "loading" || editorUndecided) {
+ return panel();
+ }
+ // No schema yet: the page (or any block) as plain fields, never an error.
+ if (decofile.data && isNoSchemaMeta(meta.data)) {
+ const target = workspace.state.target;
+ const initialKey =
+ target?.kind === "page" || target?.kind === "section" ? target.key : null;
+ return panel(
+ ,
+ );
+ }
if (state.kind === "empty") return panel();
if (state.kind === "error") {
const retry = () => {
diff --git a/apps/web/src/components/sandbox/content/app-editor.tsx b/apps/web/src/components/sandbox/content/app-editor.tsx
index 13f4e3be63..ee0ea98d17 100644
--- a/apps/web/src/components/sandbox/content/app-editor.tsx
+++ b/apps/web/src/components/sandbox/content/app-editor.tsx
@@ -1,7 +1,7 @@
import { useOptionalChatTask } from "@/components/chat/chat-context";
import { Spinner } from "@decocms/ui/components/spinner.tsx";
import { ChevronRight } from "@untitledui/icons";
-import { useRef, useState } from "react";
+import { type ReactNode, useRef, useState } from "react";
import { toast } from "sonner";
import { cn } from "@decocms/ui/lib/utils.ts";
import { ScrollArea } from "@decocms/ui/components/scroll-area.tsx";
@@ -12,6 +12,7 @@ import type { LiveMeta } from "@/components/sections-editor/resolve-schema";
import type { SectionCatalogEntry } from "@/components/sections-editor/section-catalog";
import { createReferencedBlockSaver } from "@/components/sections-editor/save-referenced-block";
import {
+ type SaveGuard,
useDebouncedSaveBlock,
useSaveBlock,
} from "@/components/sections-editor/use-save-block";
@@ -38,6 +39,8 @@ export function AppEditor({
excludeFields,
schemaPending = false,
previewBaseUrl = null,
+ notice,
+ saveGuard,
}: {
orgSlug: string;
virtualMcpId: string;
@@ -51,6 +54,10 @@ export function AppEditor({
excludeFields?: readonly string[];
schemaPending?: boolean;
previewBaseUrl?: string | null;
+ /** Shown above the form (e.g. that the block is not saved yet). */
+ notice?: ReactNode;
+ /** A version guard for this block's autosaves (e.g. create-only on the first save). */
+ saveGuard?: SaveGuard;
}) {
const threadId = useOptionalChatTask()?.taskId ?? null;
const t = useT();
@@ -58,6 +65,7 @@ export function AppEditor({
// back to the Fast Preview production deployment while the sandbox boots.
const sectionPreviewBase = useSectionPreviewBase({
virtualMcpId,
+ branch,
sandboxUrl: previewBaseUrl,
});
const resolveType =
@@ -68,11 +76,10 @@ export function AppEditor({
const title =
titleOverride ?? (block ? appLabel(blockKey, block, meta) : blockKey);
- const { save, flush, isPending } = useDebouncedSaveBlock({
- orgSlug,
- virtualMcpId,
- branch,
- });
+ const { save, flush, isPending } = useDebouncedSaveBlock(
+ { orgSlug, virtualMcpId, branch },
+ { guard: saveGuard },
+ );
const saveBlock = useSaveBlock({ orgSlug, virtualMcpId, branch });
const saveReferencedBlock = createReferencedBlockSaver((refKey, data) =>
save(refKey, data),
@@ -212,6 +219,7 @@ export function AppEditor({
+ {notice}
{hasEditableFields ? (
;
+ initialKey?: string | null;
+ compact?: boolean;
+}) {
+ const { save, flush } = useDebouncedSaveBlock({
+ orgSlug,
+ virtualMcpId,
+ branch,
+ });
+ // Saves what's pending when the editor goes away (the debounce alone
+ // would drop it): runs after the save hook's own timer cleanup.
+ const flushPending = useEffectEvent(() => flush());
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect -- flush pending autosaves on unmount
+ useEffect(() => () => flushPending(), []);
+ return (
+ save(blockKey, next)}
+ onLeaveBlock={flush}
+ initialKey={initialKey}
+ compact={compact}
+ />
+ );
+}
diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx
index d6c793e05a..384e2daa34 100644
--- a/apps/web/src/components/sandbox/preview/preview.tsx
+++ b/apps/web/src/components/sandbox/preview/preview.tsx
@@ -21,7 +21,7 @@ import {
import { Button } from "@decocms/ui/components/button.tsx";
import { useState, useRef, useEffect } from "react";
import { Spinner } from "@decocms/ui/components/spinner.tsx";
-import { useChatTask } from "@/components/chat/context";
+import { useChatTask, useOptionalChatStream } from "@/components/chat/context";
import { useProjectContext } from "@/sdk";
import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context";
import { useVirtualMCPNonBlocking } from "@/sdk";
@@ -54,8 +54,16 @@ import {
} from "@decocms/ui/components/tooltip.tsx";
import { ToolbarIconButton } from "@/components/toolbar-icon-button";
import { Panel } from "@/components/panel";
-import { useDecofile } from "@/components/sections-editor/use-decofile";
+import {
+ useDecofile,
+ useDecofileCacheKey,
+} from "@/components/sections-editor/use-decofile";
import { withVariantMatcherOverride } from "@/components/sections-editor/variant-matcher-override";
+import {
+ buildServeDraftPointer,
+ withForcedVariantsInDraftUrl,
+} from "@/components/sections-editor/variant-draft-pointer";
+import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection";
import { useLiveMeta } from "@/components/sections-editor/use-live-meta";
import {
extractGlobalSections,
@@ -118,7 +126,7 @@ import {
} from "./path-param-picker-chip";
import { PathParamInput } from "./path-param-input";
import { buildPreviewLabel } from "./preview-label";
-import { showCmsPageSelector } from "./cms-controls";
+import { showCmsPageSelector, showPreviewToolbarFor } from "./cms-controls";
import { useCreatePage } from "@/components/sections-editor/use-create-page";
import { CreatePageModal } from "@/components/sections-editor/create-page-modal";
import { sleep } from "@decocms/shared/std";
@@ -147,6 +155,12 @@ import {
toggleVisualEditingMode,
type PreviewEditingMode,
} from "./editing-mode";
+import {
+ isProtocolProject,
+ servePreviewUrl,
+} from "@/components/sections-editor/content-backend";
+import { useContentBackend } from "@/components/sections-editor/use-content-backend";
+import { useContentRevision } from "@/components/sections-editor/content-protocol-api";
import { isContentEditingEnabled } from "@/layouts/main-panel-tabs/content-editing-gate";
import { type PreviewDeviceSize, withDeviceHint } from "./device-hint";
@@ -339,6 +353,11 @@ function reloadIframeOrFallback(
}
}
+/** How long after a `deco serve` save the frame may take to reload itself
+ * before the editor reloads it: a dev server that reloads pages on content
+ * changes does so within a few hundred ms of the save. */
+const SERVE_SELF_RELOAD_WAIT_MS = 1500;
+
export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const t = useT();
const isDesktopApp = useIsDesktopApp();
@@ -349,6 +368,9 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
taskId: activeTaskId,
virtualMcpId: sessionAgentId,
} = useChatTask();
+ /** Visual editing asks the chat about a clicked element: none without one
+ * (the account-less `/site-editor`). */
+ const hasChat = !!useOptionalChatStream();
const workspace = useBlocksPreviewWorkspace();
const agent = useVirtualMCPNonBlocking(
sessionAgentId === virtualMcpId ? virtualMcpId : null,
@@ -467,7 +489,16 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
* iframe, and the editor bridge all target it — and the boot gating is
* bypassed (the tunnel is already up), so no pod ever boots.
*/
- const { url: localPreviewUrl } = useLocalPreviewUrl(virtualMcpId);
+ const { url: tunnelUrl } = useLocalPreviewUrl(virtualMcpId);
+ /**
+ * A connected `deco serve` (content protocol) stands in the same way: its
+ * app preview is the dev server on this machine. The protocol never runs
+ * site code, so nothing renders in place: the frame is the real app.
+ */
+ const contentBackend = useContentBackend(virtualMcpId, branch);
+ const { connection: serveConnection } = useDecoServeConnection(virtualMcpId);
+ const runsSiteCode = !isProtocolProject(contentBackend);
+ const localPreviewUrl = servePreviewUrl(contentBackend) ?? tunnelUrl;
const previewUrl = localPreviewUrl ?? lifecycle.previewUrl;
const devServerReady = !!localPreviewUrl || lifecyclePhase === "running";
@@ -485,11 +516,13 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const projectDefaultsToCms = session.projectDefault === "cms";
// Base for the `/live/previews` global-section render: production under Fast Preview (no dev server), else the sandbox dev server.
- const sectionPreviewBase = resolveSectionPreviewBase({
- sandboxUrl: previewUrl,
- previewServerUrl,
- fastPreviewActive: fastPreviewEnabled,
- });
+ const sectionPreviewBase = runsSiteCode
+ ? resolveSectionPreviewBase({
+ sandboxUrl: previewUrl,
+ previewServerUrl,
+ fastPreviewActive: fastPreviewEnabled,
+ })
+ : null;
// 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
@@ -515,7 +548,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const decofile = decofileQuery.data;
const meta = metaQuery.data;
const pages = decofile
- ? extractPages(decofile).sort((a, b) => a.name.localeCompare(b.name))
+ ? extractPages(decofile, meta).sort((a, b) => a.name.localeCompare(b.name))
: [];
const createPageParams =
virtualMcpId && branch ? { orgSlug: org.slug, virtualMcpId, branch } : null;
@@ -523,7 +556,9 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const globalSections =
decofile && meta ? extractGlobalSections(decofile, meta) : [];
const globalLoaders =
- decofile && meta ? listSavedRunnables(meta, decofile, "loaders") : [];
+ runsSiteCode && decofile && meta
+ ? listSavedRunnables(meta, decofile, "loaders")
+ : [];
const filteredPages = !pagesSearch
? pages
: (() => {
@@ -593,7 +628,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
// whatever product-search / category loader the running site actually ships.
// A param with no resolvable source keeps the plain inline input.
const pathParamSources: Record = {};
- if (devServerReady && previewUrl && meta && decofile) {
+ if (runsSiteCode && devServerReady && previewUrl && meta && decofile) {
const manifestLoaders = manifestLoaderResolveTypes(meta, "loaders");
const pageBlock = currentPageKey ? decofile[currentPageKey] : undefined;
const pageLoaders = collectPageLoaderResolveTypes(
@@ -759,15 +794,17 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const inPlaceRenderEnabled =
agent?.id === virtualMcpId && agent.metadata?.fastPreviewInPlace === true;
// Local renders fake edits in place against the tunnel's `/live/previews`.
- const inPlaceRenderActive = localPreviewUrl
- ? display.mode === "sandbox" &&
- blocksEditingEnabled &&
- editingMode === "blocks"
- : display.mode === "production" &&
- fastPreviewEnabled &&
- inPlaceRenderEnabled &&
- blocksEditingEnabled &&
- editingMode === "blocks";
+ const inPlaceRenderActive = !runsSiteCode
+ ? false
+ : localPreviewUrl
+ ? display.mode === "sandbox" &&
+ blocksEditingEnabled &&
+ editingMode === "blocks"
+ : display.mode === "production" &&
+ fastPreviewEnabled &&
+ inPlaceRenderEnabled &&
+ blocksEditingEnabled &&
+ editingMode === "blocks";
// Frozen against autosave version bumps, re-latched on page switch — see resolveInPlaceDraftUrl.
const pinnedDraftUrlRef = useRef(null);
const { pin: nextPinnedDraft, effective: effectiveDraftPreviewUrl } =
@@ -780,32 +817,51 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
// oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- persist the frozen/relatched pin for the next render's decision
pinnedDraftUrlRef.current = nextPinnedDraft;
+ // The selected variant: forced variants in the `?__draft=` pointer on a
+ // content-protocol (v8) site, `x-deco-matchers-override` on a legacy one.
+ const variantOverride = workspace.state.variantOverride ?? [];
+ const matcherOverride = runsSiteCode ? variantOverride : [];
+ const forcedVariants = runsSiteCode ? [] : variantOverride;
+ // A connected `deco serve` has no drafts: a pointer to it carries only the
+ // forced variants. With none, `off` drops a pointer the draft cookie kept.
+ const servePointer =
+ serveConnection && servePreviewUrl(contentBackend)
+ ? (buildServeDraftPointer(serveConnection.endpoint, forcedVariants) ??
+ DRAFT_OFF)
+ : null;
+
const iframeSrc = withDecoFBT(
display.mode === "sandbox" && externalOriginReady
? withVariantMatcherOverride(
withDeviceHint(
- directPreviewUrl ??
- resolvePreviewUrl(resolvedPath, display.iframeBase!) ??
- display.iframeBase!,
+ withDraftPointer(
+ directPreviewUrl ??
+ resolvePreviewUrl(resolvedPath, display.iframeBase!) ??
+ display.iframeBase!,
+ servePointer,
+ ),
previewDeviceSize,
),
- workspace.state.variantOverride ?? [],
+ matcherOverride,
)
: display.mode === "production" && externalOriginReady
? // Fast Preview's draft route honours the variant matcher override like the sandbox dev server, so append it here too.
withVariantMatcherOverride(
withDeviceHint(
- // Waking pill up ⇒ no draft is renderable yet, so ask for published.
- withDraftPointer(
- directPreviewUrl ??
- effectiveDraftPreviewUrl ??
- resolvePreviewUrl(resolvedPath, display.iframeBase!) ??
- display.iframeBase!,
- display.showWakingPill ? DRAFT_OFF : null,
+ withForcedVariantsInDraftUrl(
+ // Waking pill up ⇒ no draft is renderable yet, so ask for published.
+ withDraftPointer(
+ directPreviewUrl ??
+ effectiveDraftPreviewUrl ??
+ resolvePreviewUrl(resolvedPath, display.iframeBase!) ??
+ display.iframeBase!,
+ display.showWakingPill ? DRAFT_OFF : null,
+ ),
+ forcedVariants,
),
previewDeviceSize,
),
- workspace.state.variantOverride ?? [],
+ matcherOverride,
)
: null,
);
@@ -856,8 +912,8 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
display.mode === "production"
? productionOpenTabBase
? withVariantMatcherOverride(
- productionOpenTabBase,
- workspace.state.variantOverride ?? [],
+ withForcedVariantsInDraftUrl(productionOpenTabBase, forcedVariants),
+ matcherOverride,
)
: null
: (iframeSrc ?? display.iframeBase);
@@ -1042,10 +1098,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
});
if (!req) return;
// Carry the selected variant like the reload-based `iframeSrc` does; else the runtime renders the default variant.
- const src = withVariantMatcherOverride(
- req.src,
- workspace.state.variantOverride ?? [],
- );
+ const src = withVariantMatcherOverride(req.src, matcherOverride);
win.postMessage(
{ type: "cms-editor::render", src, body: req.body },
origin,
@@ -1315,6 +1368,37 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
// Only a moved or restarted dev server; file edits reload the page themselves.
useSandboxReloadHandler(reloadPreviewPreservingScroll);
+ // A connected `deco serve` is the exception: a save rewrites the site's
+ // content module, which only the server imports, so the dev server may
+ // swap it without reloading the page in the frame. A new content revision
+ // (a save here, or a read that found the files changed) waits for the
+ // frame to reload itself, as a dev server that reloads on content changes
+ // does, and reloads it otherwise.
+ const contentCacheKey = useDecofileCacheKey(createPageParams);
+ const contentRevision = useContentRevision(
+ servePreviewUrl(contentBackend) ? contentCacheKey : "",
+ );
+ const shownRevisionRef = useRef(contentRevision);
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect -- imperative iframe reload in response to a content revision the query cache moved
+ useEffect(() => {
+ const shown = shownRevisionRef.current;
+ shownRevisionRef.current = contentRevision;
+ if (!shown || !contentRevision || shown === contentRevision) return;
+ const iframe = previewIframeRef.current;
+ if (!iframe) return;
+ const reloadedItself = () => clearTimeout(timer);
+ iframe.addEventListener("load", reloadedItself, { once: true });
+ const timer = setTimeout(() => {
+ iframe.removeEventListener("load", reloadedItself);
+ reloadPreviewPreservingScroll();
+ }, SERVE_SELF_RELOAD_WAIT_MS);
+ return () => {
+ clearTimeout(timer);
+ iframe.removeEventListener("load", reloadedItself);
+ };
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- reload on a revision change only; the reload reads refs
+ }, [contentRevision]);
+
const handleDeviceToggle = () => {
const idx = DEVICE_CYCLE.indexOf(previewDeviceSize);
setPreviewDeviceSize(DEVICE_CYCLE[(idx + 1) % DEVICE_CYCLE.length]!);
@@ -1456,8 +1540,12 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
}
};
- const showPreviewToolbar =
- previewSurfaceActive && (daemonReady || display.mode === "production");
+ const showPreviewToolbar = showPreviewToolbarFor({
+ previewSurfaceActive,
+ daemonReady,
+ production: display.mode === "production",
+ servePreviewUrl: servePreviewUrl(contentBackend),
+ });
/** The page selector shares the exact project-level gate used by Content and
* Blocks. Session runtime and metadata readiness do not change the topbar's
@@ -1722,7 +1810,9 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
// Desktop composition (portaled into the panel header's centre slot).
- const canVisualEdit = display.mode === "sandbox";
+ // A v7 site keeps visual editing as before; a v8 one needs a chat to ask
+ // (none in the account-less `/site-editor`).
+ const canVisualEdit = (runsSiteCode || hasChat) && display.mode === "sandbox";
// Desktop stays fluid until the canvas is narrower than its logical width; then (and always for mobile/tablet) the frame scales to fit.
const previewViewport = PREVIEW_VIEWPORTS[previewDeviceSize];
diff --git a/apps/web/src/components/sections-editor/sections-editor.tsx b/apps/web/src/components/sections-editor/sections-editor.tsx
index e462bd31e2..276fe5388b 100644
--- a/apps/web/src/components/sections-editor/sections-editor.tsx
+++ b/apps/web/src/components/sections-editor/sections-editor.tsx
@@ -25,7 +25,7 @@ import {
import { cn } from "@decocms/ui/lib/utils.ts";
import { VariantRenameDialog } from "./variant-rename-dialog";
import { toast } from "sonner";
-import { useDecofile } from "./use-decofile";
+import { useDecofile, useDecofileCacheKey } from "./use-decofile";
import { useLiveMeta } from "./use-live-meta";
import { useDeleteBlock } from "./use-delete-block";
import {
@@ -50,6 +50,8 @@ import { PageVariantTabs, VariantTabIcon } from "./page-variant-tabs";
import { MakeReusableModal } from "./make-reusable-modal";
import { AddSectionModal } from "./add-section-modal";
import { useSectionPreviewBase } from "./use-section-preview-base";
+import { useContentBackend } from "./use-content-backend";
+import { isProtocolProject } from "./content-backend";
import type { SectionCatalogEntry } from "./section-catalog";
import { SectionVariantList } from "./section-variant-list";
import type { Crumb } from "./schema-form-breadcrumb";
@@ -114,6 +116,10 @@ import {
buildSectionVariantOverrideParams,
type PageVariantInfo,
} from "./variant-matcher-override";
+import {
+ buildPageForcedVariants,
+ buildSectionForcedVariants,
+} from "./variant-draft-pointer";
import { PageJsonDialog } from "./page-json-dialog";
import { createReferencedBlockSaver } from "./save-referenced-block";
import { formatMatcher } from "./format-matcher";
@@ -188,9 +194,11 @@ export function SectionsEditor({
onFocusedBlockChange?: (blockKey: string | null) => void;
/**
* Called when the selected section variant changes so the host can force the
- * preview iframe to render that variant via `x-deco-matchers-override`.
- * Passes `null` when no variant override should be applied (non-multivariate
- * section, or nothing selected).
+ * preview iframe to render that variant: `x-deco-matchers-override` params on
+ * a legacy (v7) site, forced variants for the `?__draft=` pointer on a
+ * content-protocol (v8) site (see variant-draft-pointer). Passes `null` when
+ * no variant override should be applied (non-multivariate section, or
+ * nothing selected).
*/
onVariantPreviewOverride?: (params: string[] | null) => void;
}) {
@@ -216,8 +224,17 @@ export function SectionsEditor({
// back to the Fast Preview production deployment while the sandbox boots.
const sectionPreviewBase = useSectionPreviewBase({
virtualMcpId,
+ branch,
sandboxUrl: previewUrl,
});
+ // The content protocol never runs site code: no rendered gallery (cards show
+ // the schema's name, description and image) and no loader-backed pickers.
+ const contentBackend = useContentBackend(virtualMcpId, branch);
+ const protocolProject = isProtocolProject(contentBackend);
+ // v8 has no async rendering (migration strips Lazy wrappers): hidden once
+ // the site is known to be v8, shown as before otherwise.
+ const asyncRenderAvailable = !protocolProject;
+ const galleryAvailable = !!sectionPreviewBase || protocolProject;
const [selectedSectionIndex, setSelectedSectionIndex] = useState<
number | null
@@ -319,7 +336,10 @@ export function SectionsEditor({
);
const queryClient = useQueryClient();
- const decofileCacheKey = `${orgSlug}/${virtualMcpId}/${branch}`;
+ const decofileCacheKey = useDecofileCacheKey(
+ { orgSlug, virtualMcpId, branch },
+ { tunnel: false },
+ );
const pageBlockSave = useDebouncedSaveBlock({
orgSlug,
virtualMcpId,
@@ -364,12 +384,7 @@ export function SectionsEditor({
{ blockKey: refKey, data },
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
});
@@ -434,7 +449,7 @@ export function SectionsEditor({
);
}
- const pages = extractPages(decofile);
+ const pages = extractPages(decofile, meta);
const isGlobalBlockMode = !!activeGlobalBlockKey;
const activePage = isGlobalBlockMode
? null
@@ -576,12 +591,10 @@ export function SectionsEditor({
onVariantPreviewOverride(null);
return;
}
- const params = buildPageVariantOverrideParams(
- activePageKey,
- { multivariate: true, index, variants },
- decofile,
- meta,
- );
+ const page = { multivariate: true, index, variants };
+ const params = protocolProject
+ ? buildPageForcedVariants(activePageKey, page)
+ : buildPageVariantOverrideParams(activePageKey, page, decofile, meta);
onVariantPreviewOverride(params.length > 0 ? params : null);
};
@@ -615,26 +628,24 @@ export function SectionsEditor({
return;
}
const pageInfo = currentPageVariantInfo();
- const pageParams = buildPageVariantOverrideParams(
- activePageKey,
- pageInfo,
- decofile,
- meta,
- );
+ const pageParams = protocolProject
+ ? buildPageForcedVariants(activePageKey, pageInfo)
+ : buildPageVariantOverrideParams(activePageKey, pageInfo, decofile, meta);
if (!mvObj || sectionIndex < 0) {
onVariantPreviewOverride(pageParams.length > 0 ? pageParams : null);
return;
}
- const sectionParams = buildSectionVariantOverrideParams({
+ const section = {
pageKey: activePageKey,
page: pageInfo,
sectionIndex,
sectionLazy: parsedSections[sectionIndex]?.isLazy ?? false,
mvObj,
selectedVariantIndex: variantIndex,
- decofile,
- meta,
- });
+ };
+ const sectionParams = protocolProject
+ ? buildSectionForcedVariants(section)
+ : buildSectionVariantOverrideParams({ ...section, decofile, meta });
const params = [...pageParams, ...sectionParams];
onVariantPreviewOverride(params.length > 0 ? params : null);
};
@@ -724,7 +735,7 @@ export function SectionsEditor({
virtualMcpId,
branch,
threadId,
- previewUrl: previewUrl ?? undefined,
+ previewUrl: protocolProject ? undefined : (previewUrl ?? undefined),
siteSlug: agentSiteSlug,
};
@@ -768,12 +779,7 @@ export function SectionsEditor({
{ blockKey: activePageKey, data: fullPageData },
{
onSuccess: () => options?.onSuccess?.() ?? onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
};
@@ -832,12 +838,7 @@ export function SectionsEditor({
{ blockKey, data: nextValue },
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
return;
@@ -920,12 +921,7 @@ export function SectionsEditor({
{ blockKey: latestPageKey, data: fullPageData },
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
};
@@ -1424,7 +1420,7 @@ export function SectionsEditor({
const handleMakeReusableSubmit = async (blockId: string) => {
if (makeReusableIndex === null || !activePageKey) return;
- const validationError = validateBlockId(blockId, decofile);
+ const validationError = validateBlockId(blockId, decofile, meta);
if (validationError) {
toast.error(validationError);
return;
@@ -1504,7 +1500,7 @@ export function SectionsEditor({
const availableMatcherGlobals =
meta && decofile ? extractMatcherGlobals(meta, decofile) : [];
const canAddSection =
- !isGlobalBlockMode && !!(sectionPreviewBase && meta && decofile);
+ !isGlobalBlockMode && !!(galleryAvailable && meta && decofile);
const ruleSchema =
ruleResolveType && meta ? resolveSchema(ruleResolveType, meta) : null;
@@ -1585,12 +1581,7 @@ export function SectionsEditor({
},
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
return;
@@ -1608,12 +1599,7 @@ export function SectionsEditor({
{ blockKey: latestPageKey, data: fullPageData },
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
}, AUTOSAVE_DELAY);
@@ -1763,12 +1749,7 @@ export function SectionsEditor({
},
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
return;
@@ -1798,12 +1779,7 @@ export function SectionsEditor({
{ blockKey: latestPageKey, data: fullPageData },
{
onSuccess: () => onSaved?.(),
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
}, AUTOSAVE_DELAY);
@@ -2144,12 +2120,7 @@ export function SectionsEditor({
setRuleFormValue(null);
setRuleResolveType(null);
},
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
};
@@ -2214,12 +2185,7 @@ export function SectionsEditor({
}
})();
},
- onError: (err) =>
- toast.error(
- t("sectionsEditor.sectionsEditor.saveFailed", {
- error: err.message,
- }),
- ),
+ onError: saveBlock.reportError,
},
);
};
@@ -2448,7 +2414,7 @@ export function SectionsEditor({
}
const blockId = suggestBlockId(trimmed);
- const validationError = validateBlockId(blockId, latestDecofile);
+ const validationError = validateBlockId(blockId, latestDecofile, meta);
if (validationError) {
toast.error(validationError);
return;
@@ -2615,7 +2581,7 @@ export function SectionsEditor({
}
const blockId = suggestBlockId(trimmed);
- const validationError = validateBlockId(blockId, latestDecofile);
+ const validationError = validateBlockId(blockId, latestDecofile, meta);
if (validationError) {
toast.error(validationError);
return;
@@ -3233,7 +3199,7 @@ export function SectionsEditor({
onDuplicate={handleDuplicateSection}
onMakeReusable={setMakeReusableIndex}
onToggleHidden={handleToggleHidden}
- onToggleLazy={handleToggleLazy}
+ onToggleLazy={asyncRenderAvailable ? handleToggleLazy : undefined}
onAddVariant={handleAddSectionVariant}
onDetach={handleDetachSection}
onAddSection={() => setAddSectionOpen(true)}
@@ -3267,6 +3233,7 @@ export function SectionsEditor({
onInnerChange={handleSeoInnerChange}
onClearForm={clearSeoForm}
onBumpFormKey={bumpSeoFormKey}
+ asyncRenderAvailable={asyncRenderAvailable}
/>
) : (
@@ -3300,6 +3267,7 @@ export function SectionsEditor({
onInnerChange={handleSeoInnerChange}
onClearForm={clearSeoForm}
onBumpFormKey={bumpSeoFormKey}
+ asyncRenderAvailable={asyncRenderAvailable}
/>
) : (
@@ -3660,7 +3628,7 @@ export function SectionsEditor({
onSubmit={handleMakeReusableSubmit}
/>
- {sectionPreviewBase && (
+ {galleryAvailable && (
{
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 53ab98e078..2c7512e499 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,6 +1,8 @@
import { useSessionRuntime } from "@/hooks/use-session-runtime";
import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url";
import { resolveSectionPreviewBase } from "./section-preview-url";
+import { useContentBackend } from "./use-content-backend";
+import { isProtocolProject } from "./content-backend";
/**
* Effective base origin for the Add Section gallery previews.
@@ -14,11 +16,15 @@ import { resolveSectionPreviewBase } from "./section-preview-url";
*/
export function useSectionPreviewBase(input: {
virtualMcpId: string;
+ branch: string;
sandboxUrl: string | null | undefined;
}): string | null {
const { previewServerUrl, runtime } = useSessionRuntime(input.virtualMcpId);
const { url: localPreviewUrl } = useLocalPreviewUrl(input.virtualMcpId);
+ const backend = useContentBackend(input.virtualMcpId, input.branch);
const active = runtime === "cms";
+ // The content protocol never runs site code, so nothing renders a preview.
+ if (isProtocolProject(backend)) return null;
// Local tunnel is a live deco dev server: render the gallery against it.
if (localPreviewUrl) return localPreviewUrl;
return resolveSectionPreviewBase({
diff --git a/apps/web/src/layouts/main-panel-tabs/preview-tab.tsx b/apps/web/src/layouts/main-panel-tabs/preview-tab.tsx
index 2860e1a56c..0674d8bb8b 100644
--- a/apps/web/src/layouts/main-panel-tabs/preview-tab.tsx
+++ b/apps/web/src/layouts/main-panel-tabs/preview-tab.tsx
@@ -1,8 +1,10 @@
import { useState } from "react";
+import { NewBlocksEditorProvider } from "@/hooks/use-new-blocks-editor";
import { useChatTask } from "@/components/chat/chat-context";
import { PreviewContent } from "@/components/sandbox/preview/preview";
import { agentHasClonableSource } from "@/lib/agent-capabilities";
import { useVirtualMCP } from "@/sdk";
+import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection";
import { Button } from "@decocms/ui/components/button.tsx";
import { EmptyState } from "@/components/empty-state";
import { GitHubIcon } from "@/components/icons/github-icon";
@@ -14,6 +16,7 @@ import { useTaskMetadata } from "./use-task-metadata";
export function PreviewTab({ virtualMcpId }: { virtualMcpId: string }) {
const t = useT();
const entity = useVirtualMCP(virtualMcpId);
+ const { connection: serveConnection } = useDecoServeConnection(virtualMcpId);
const { activeTask, taskId, currentBranch } = useChatTask();
const threadMetadata = useTaskMetadata(taskId);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -30,7 +33,8 @@ export function PreviewTab({ virtualMcpId }: { virtualMcpId: string }) {
agentHasClonableSource(activeTask?.metadata),
});
- if (previewSource === "none") {
+ // A connected `deco serve` is a source of its own (`/site-editor` has no repo).
+ if (previewSource === "none" && !serveConnection) {
return (
<>
;
+ return (
+
+
+
+ );
}
diff --git a/apps/web/src/layouts/main-panel-tabs/use-task-metadata.ts b/apps/web/src/layouts/main-panel-tabs/use-task-metadata.ts
index fa60699fbc..9a0b731659 100644
--- a/apps/web/src/layouts/main-panel-tabs/use-task-metadata.ts
+++ b/apps/web/src/layouts/main-panel-tabs/use-task-metadata.ts
@@ -12,7 +12,8 @@
* archived-thread fallback, and `localHit` always wins over it.
*
* `taskId: null` — a destination route with no thread open — resolves to `null`
- * without touching the wire.
+ * without touching the wire. Outside any thread store (the account-less
+ * `/site-editor`, which has no chat) there are no local rows to read.
*/
import { useSyncExternalStore } from "react";
@@ -20,17 +21,21 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { useProjectContext } from "@/sdk";
import { KEYS } from "@/lib/query-keys";
import { useStudioTools } from "@/lib/studio-tools";
-import { useThreadManager } from "@/components/chat/store/hooks";
+import { useOptionalThreadManager } from "@/components/chat/store/hooks";
import type { ThreadMetadata } from "@decocms/shared/entities";
import type { Task } from "@/components/chat/task/types";
+const NO_THREADS: Task[] = [];
+const noSubscription = () => () => {};
+const noThreads = () => NO_THREADS;
+
export function useTaskMetadata(taskId: string | null): ThreadMetadata | null {
const { org } = useProjectContext();
const studio = useStudioTools();
- const manager = useThreadManager();
+ const manager = useOptionalThreadManager();
const threads = useSyncExternalStore(
- manager.threads.subscribe,
- manager.threads.get,
+ manager?.threads.subscribe ?? noSubscription,
+ manager?.threads.get ?? noThreads,
);
const localHit =
taskId === null ? null : (threads.find((t) => t.id === taskId) ?? null);
diff --git a/knip.jsonc b/knip.jsonc
index cb9820bcdb..b31c10b75a 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -83,10 +83,6 @@
"exports"
],
"apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"],
- "apps/web/src/components/sections-editor/content-protocol-api.ts": [
- "exports"
- ],
- "apps/web/src/hooks/use-new-blocks-editor.ts": ["exports"],
"apps/web/src/components/sections-editor/deco-serve-notices.tsx": [
"exports"
],