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" ],