From 71a28372251fd5e3c3730cbb38f26896057975ba Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:08:48 -0300 Subject: [PATCH 1/2] feat(web): the Local draft option connects deco serve The Local draft option connects a `deco serve`: pasting its link connects, and the header drops repo actions while serving locally. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/content-version-badge.tsx | 42 +++++ .../sections-editor/deco-serve-chip.tsx | 99 ++++++++++ .../thread/repository/branch-picker.tsx | 127 +++++++++++-- .../repository/local-url-submit.test.ts | 56 ++++++ .../thread/repository/local-url-submit.ts | 47 +++++ .../web/src/hooks/use-deco-serve-discovery.ts | 171 ++++++++++++++++++ apps/web/src/i18n/en/thread.ts | 2 + apps/web/src/i18n/pt-br/thread.ts | 2 + .../routes/workspace/agent-site-editor.tsx | 63 +++++-- knip.jsonc | 5 +- 10 files changed, 572 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/sections-editor/content-version-badge.tsx create mode 100644 apps/web/src/components/sections-editor/deco-serve-chip.tsx create mode 100644 apps/web/src/components/thread/repository/local-url-submit.test.ts create mode 100644 apps/web/src/components/thread/repository/local-url-submit.ts create mode 100644 apps/web/src/hooks/use-deco-serve-discovery.ts diff --git a/apps/web/src/components/sections-editor/content-version-badge.tsx b/apps/web/src/components/sections-editor/content-version-badge.tsx new file mode 100644 index 0000000000..6501fa4e83 --- /dev/null +++ b/apps/web/src/components/sections-editor/content-version-badge.tsx @@ -0,0 +1,42 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { useT } from "@/i18n/use-t.ts"; +import { useContentBackend } from "./use-content-backend"; + +/** + * Marks a Blocks v8 site: one edited over the content protocol, from a + * GitHub schema with `"blocksMajor": 8` or a `deco serve` (connected or + * reconnecting). v7 sites, and sites still being detected, show nothing: + * the editor looks as it did before next-major Blocks. + */ +export function ContentVersionBadge({ + virtualMcpId, + branch, +}: { + virtualMcpId: string; + branch: string | null; +}) { + const t = useT(); + const backend = useContentBackend(virtualMcpId, branch); + if (backend.kind !== "protocol" && backend.kind !== "unavailable") { + return null; + } + return ( + + + + v8 + + + + {t("decoServe.version.v8")} + + + ); +} diff --git a/apps/web/src/components/sections-editor/deco-serve-chip.tsx b/apps/web/src/components/sections-editor/deco-serve-chip.tsx new file mode 100644 index 0000000000..721e3981df --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-chip.tsx @@ -0,0 +1,99 @@ +import type { ReactNode } from "react"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection"; +import { useT } from "@/i18n/use-t.ts"; +import { endpointHost } from "./deco-serve-connection"; +import { RichCode, serveProblemShort } from "./deco-serve-notices"; +import { useContentBackend } from "./use-content-backend"; + +/** The site editor's "Local server" chip: what it edits and its state. */ +export function DecoServeChip({ + virtualMcpId, + branch, +}: { + virtualMcpId: string; + branch: string | null; +}) { + const t = useT(); + const { connection } = useDecoServeConnection(virtualMcpId); + const backend = useContentBackend(virtualMcpId, branch); + if (!connection) return null; + const problem = + backend.kind === "unavailable" + ? serveProblemShort( + t, + backend.problem ?? { reason: "not-answering" }, + endpointHost(connection.endpoint), + ) + : null; + const readOnly = + backend.kind === "protocol" && + backend.source === "local" && + backend.describe.readOnly; + return ( +
+ + + + {t("decoServe.chip.label")} + + + + {problem ?? t("decoServe.chip.tooltip")} + + + {readOnly && ( + + + + {t("decoServe.chip.readOnly")} + + + + + + + )} +
+ ); +} + +/** The connect flow's centered column; `fullScreen` outside the app shell. */ +export function ConnectCentered({ + children, + fullScreen = false, +}: { + children: ReactNode; + fullScreen?: boolean; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/thread/repository/branch-picker.tsx b/apps/web/src/components/thread/repository/branch-picker.tsx index a036448d2f..9124831713 100644 --- a/apps/web/src/components/thread/repository/branch-picker.tsx +++ b/apps/web/src/components/thread/repository/branch-picker.tsx @@ -51,14 +51,23 @@ import { Trash01, } from "@untitledui/icons"; import { generateBranchName } from "@decocms/shared/branch-name"; -import { productionUrlFromDomain } from "@decocms/shared/deco-site-production-url"; import { RELEASES_MAX, type Release } from "@decocms/shared/sdk/types"; import type { SandboxMap } from "@/sdk"; import { useMembersQuery } from "@/hooks/use-members"; import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; +import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection"; +import { + type DecoServeConnection, + classifyServeProbeError, + endpointHost, +} from "@/components/sections-editor/deco-serve-connection"; +import { serveProblemShort } from "@/components/sections-editor/deco-serve-notices"; +import { DISCOVERY_PROBE_TIMEOUT_MS } from "@/components/sections-editor/deco-serve-discovery"; +import { probeServeEndpoint } from "@/hooks/use-deco-serve-discovery"; import { useT } from "@/i18n/use-t.ts"; import { toast } from "sonner"; import { decodeHtmlEntities } from "./decode-html-entities.ts"; +import { resolveLocalSubmit } from "./local-url-submit.ts"; import { matchesBranchSearch, useBranches } from "./use-branches"; import { useOpenPrs } from "./use-pr-data.ts"; import { @@ -143,16 +152,23 @@ export function BranchPicker({ const { releases, createRelease, renameRelease, deleteRelease } = useReleases(virtualMcpId); /** - * "Local" mode: a tunnel URL that overrides preview + CMS meta for this - * project (per-browser). Orthogonal to the branch `value` — picking any - * branch/draft clears it (see `pick`/`create`/`adoptBranch`). + * "Local" mode, per-browser and orthogonal to the branch `value` — picking + * any branch/draft clears it (see `pick`/`create`/`adoptBranch`). Either a + * `deco serve` link (v8: the content protocol to this machine) or, for v7 + * sites, a tunnel URL that overrides preview + CMS meta. */ const { - url: localUrl, - setUrl: setLocalUrl, - clear: clearLocal, + url: tunnelUrl, + setUrl: setTunnelUrl, + clear: clearTunnel, } = useLocalPreviewUrl(virtualMcpId); + const serve = useDecoServeConnection(virtualMcpId); + const localUrl = serve.connection?.endpoint ?? tunnelUrl; const localActive = !!localUrl; + const clearLocal = () => { + clearTunnel(); + serve.clear(); + }; // Non-suspense + deferred to `open`: the trigger renders before members load. const { data: membersData } = useMembersQuery({ enabled: open }); const creatorName = (id: string | undefined): string | undefined => @@ -204,7 +220,16 @@ export function BranchPicker({ // Save + activate a Local tunnel URL, or turn it off when cleared. const activateLocal = (url: string | null) => { - setLocalUrl(url); + serve.clear(); + setTunnelUrl(url); + setAdvanced(false); + setOpen(false); + }; + + // Connect a `deco serve` that answered `describe` (a v8 site). + const activateServe = (connection: DecoServeConnection) => { + clearTunnel(); + serve.set(connection); setAdvanced(false); setOpen(false); }; @@ -402,8 +427,10 @@ export function BranchPicker({ enabled={open} tab={advancedTab} onTabChange={setAdvancedTab} - localUrl={localUrl} + localUrl={tunnelUrl} + serveEndpoint={serve.connection?.endpoint ?? null} onSaveLocal={activateLocal} + onSaveServe={activateServe} onBack={() => setAdvanced(false)} onAdopt={adoptBranch} /> @@ -755,7 +782,9 @@ function AdvancedPicker({ tab, onTabChange, localUrl, + serveEndpoint, onSaveLocal, + onSaveServe, onBack, onAdopt, }: { @@ -769,10 +798,13 @@ function AdvancedPicker({ enabled: boolean; tab: "branches" | "prs" | "local"; onTabChange: (tab: "branches" | "prs" | "local") => void; - /** Currently-registered Local URL, prefilled into the form. */ + /** Currently-registered Local tunnel URL, prefilled into the form. */ localUrl: string | null; + /** The connected `deco serve`'s endpoint, if any. */ + serveEndpoint: string | null; /** Save + activate a Local URL, or turn it off with `null`. */ onSaveLocal: (url: string | null) => void; + onSaveServe: (connection: DecoServeConnection) => void; onBack: () => void; onAdopt: (branch: string, name: string) => void; }) { @@ -852,7 +884,12 @@ function AdvancedPicker({ {tab === "local" ? ( - + ) : ( void; + onSaveServe: (connection: DecoServeConnection) => void; }) { const t = useT(); const [value, setValue] = useState(url ?? ""); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); const trimmed = value.trim(); - const submit = () => - onSave(trimmed ? productionUrlFromDomain(trimmed) : null); + const submit = async () => { + // The first request is what makes Chrome ask to reach this machine. + setChecking(true); + setError(null); + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DISCOVERY_PROBE_TIMEOUT_MS, + ); + try { + const outcome = await resolveLocalSubmit(trimmed, (endpoint) => + probeServeEndpoint(endpoint, controller.signal), + ); + if (outcome.kind === "serve") onSaveServe(outcome.connection); + else if (outcome.kind === "tunnel") onSave(outcome.url); + else { + setError( + serveProblemShort( + t, + classifyServeProbeError(outcome.failure), + endpointHost(outcome.connection.endpoint), + ), + ); + } + } finally { + clearTimeout(timeout); + setChecking(false); + } + }; return (

- {t("thread.branchPicker.localHint")} + {serveEndpoint + ? t("thread.branchPicker.localServeConnected", { + host: endpointHost(serveEndpoint), + }) + : t("thread.branchPicker.localHint")}

setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + setError(null); + }} onKeyDown={(e) => { - if (e.key === "Enter" && trimmed) submit(); + if (e.key === "Enter" && trimmed && !checking) void submit(); }} className="h-8 w-full rounded-md border bg-transparent px-2 text-sm outline-none focus:ring-1 focus:ring-ring" /> + {error && ( +

+ {error} +

+ )}
- {url ? ( + {url || serveEndpoint ? (
diff --git a/apps/web/src/components/thread/repository/local-url-submit.test.ts b/apps/web/src/components/thread/repository/local-url-submit.test.ts new file mode 100644 index 0000000000..ccbf70ee7f --- /dev/null +++ b/apps/web/src/components/thread/repository/local-url-submit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { productionUrlFromDomain } from "@decocms/shared/deco-site-production-url"; +import { resolveLocalSubmit } from "./local-url-submit"; + +const answers = async () => ({}); +const nothingAnswers = async () => { + throw new TypeError("Failed to fetch"); +}; + +describe("the Local option's submit", () => { + // Main saved every one of these as the tunnel URL. + const v7Local = [ + "http://localhost:8000", + "https://localhost:8000", + "http://127.0.0.1:8000/", + "localhost:3000", + "8000", + ]; + + for (const input of v7Local) { + test(`${input}: saved as a v7 tunnel when no deco serve answers`, async () => { + expect(await resolveLocalSubmit(input, nothingAnswers)).toEqual({ + kind: "tunnel", + url: productionUrlFromDomain(input), + }); + }); + } + + test("a public tunnel URL is never probed", async () => { + let probed = false; + const outcome = await resolveLocalSubmit( + " https://abc.ngrok.app ", + async () => { + probed = true; + }, + ); + expect(probed).toBe(false); + expect(outcome).toEqual({ + kind: "tunnel", + url: productionUrlFromDomain("https://abc.ngrok.app"), + }); + }); + + test("a deco serve that answers is connected", async () => { + expect(await resolveLocalSubmit("localhost:4545", answers)).toEqual({ + kind: "serve", + connection: { endpoint: "http://localhost:4545/rpc" }, + }); + }); + + test("a Site editor link whose deco serve is down reports it", async () => { + const link = `http://localhost:4000/site-editor#endpoint=${encodeURIComponent("http://localhost:4545/rpc")}`; + const outcome = await resolveLocalSubmit(link, nothingAnswers); + expect(outcome.kind).toBe("serve-error"); + }); +}); diff --git a/apps/web/src/components/thread/repository/local-url-submit.ts b/apps/web/src/components/thread/repository/local-url-submit.ts new file mode 100644 index 0000000000..b02dc4b0ab --- /dev/null +++ b/apps/web/src/components/thread/repository/local-url-submit.ts @@ -0,0 +1,47 @@ +/** + * What the draft selector's "Local" option does with what was pasted. + * + * A `deco serve` (Blocks v8) answers its probe and is connected. Anything + * else is saved as a v7 tunnel URL exactly as before `deco serve` existed — + * including `http://localhost:PORT`, `127.0.0.1:PORT` and a bare port whose + * probe failed: those are v7 dev servers as much as stopped `deco serve`s. + * Only a Site editor link, which never names anything but a `deco serve`, + * reports the probe's failure instead. + * + * Pure but for the injected probe: unit-tested without a browser. + */ + +import { productionUrlFromDomain } from "@decocms/shared/deco-site-production-url"; +import { + type DecoServeConnection, + parseConnectLink, + parseServeAddress, +} from "@/components/sections-editor/deco-serve-connection"; + +export type LocalSubmitOutcome = + /** A `deco serve` answered: connect it. */ + | { kind: "serve"; connection: DecoServeConnection } + /** Save as the v7 tunnel URL (`null` turns Local off, as on main). */ + | { kind: "tunnel"; url: string | null } + /** A Site editor link whose `deco serve` didn't answer. */ + | { kind: "serve-error"; connection: DecoServeConnection; failure: unknown }; + +export async function resolveLocalSubmit( + input: string, + probe: (endpoint: string) => Promise, +): Promise { + const trimmed = input.trim(); + const parsed = parseServeAddress(trimmed); + if (!parsed.ok) + return { kind: "tunnel", url: productionUrlFromDomain(trimmed) }; + const { connection } = parsed; + try { + await probe(connection.endpoint); + return { kind: "serve", connection }; + } catch (failure) { + if (parseConnectLink(trimmed)) { + return { kind: "serve-error", connection, failure }; + } + return { kind: "tunnel", url: productionUrlFromDomain(trimmed) }; + } +} diff --git a/apps/web/src/hooks/use-deco-serve-discovery.ts b/apps/web/src/hooks/use-deco-serve-discovery.ts new file mode 100644 index 0000000000..5008a233b2 --- /dev/null +++ b/apps/web/src/hooks/use-deco-serve-discovery.ts @@ -0,0 +1,171 @@ +import { useState, useSyncExternalStore } from "react"; +import { createContentClient } from "@decocms/blocks/protocol"; +import { isLoopbackEndpoint } from "@/components/sections-editor/deco-serve-connection"; +import { + type DiscoveryState, + startDiscovery, +} from "@/components/sections-editor/deco-serve-discovery"; +import { probe } from "@/components/sections-editor/use-content-backend"; + +/** Probes one endpoint, aborting with `signal`; throws when it can't be used. */ +export function probeServeEndpoint( + endpoint: string, + signal: AbortSignal, +): Promise { + return probe( + createContentClient({ + endpoint, + fetch: (request) => fetch(request, { signal }), + }), + ); +} + +/** + * Chrome's permission for a public page to reach this machine (Local Network + * Access). `unsupported`: the browser doesn't ask, or Studio itself runs on + * this machine, which needs no permission. `null` while it is read. + */ +export type LocalNetworkAccess = + | "granted" + | "prompt" + | "denied" + | "unsupported" + | null; + +/** Chrome has named the permission both ways while it shipped. */ +const LNA_PERMISSION_NAMES = ["loopback-network", "local-network-access"]; + +async function queryLocalNetworkAccess(): Promise { + if (isLoopbackEndpoint(window.location.origin)) return null; + for (const name of LNA_PERMISSION_NAMES) { + try { + return await navigator.permissions.query({ + name: name as PermissionName, + }); + } catch { + // Not a permission this browser knows. + } + } + return null; +} + +let lnaState: LocalNetworkAccess = null; +let lnaStarted = false; +const lnaListeners = new Set<() => void>(); + +function subscribeLocalNetworkAccess(onChange: () => void): () => void { + lnaListeners.add(onChange); + if (!lnaStarted) { + lnaStarted = true; + const notify = (next: LocalNetworkAccess) => { + lnaState = next; + for (const listener of lnaListeners) listener(); + }; + void queryLocalNetworkAccess().then((status) => { + if (!status) return notify("unsupported"); + notify(status.state as LocalNetworkAccess); + status.addEventListener("change", () => + notify(status.state as LocalNetworkAccess), + ); + }); + } + return () => lnaListeners.delete(onChange); +} + +const IDLE: DiscoveryState = { + status: "stopped", + firstRoundDone: false, + problem: null, +}; + +interface DiscoveryStore { + subscribe: (onChange: () => void) => () => void; + getSnapshot: () => DiscoveryState; + /** Set on every render by the hook, so the latest callback is called. */ + onFound: (endpoint: string) => void; +} + +/** One running search per candidate list, while something subscribes to it. */ +const stores = new Map(); + +function discoveryStore(key: string): DiscoveryStore { + const existing = stores.get(key); + if (existing) return existing; + let state: DiscoveryState = { + status: "searching", + firstRoundDone: false, + problem: null, + }; + const listeners = new Set<() => void>(); + let stop: (() => void) | null = null; + const store: DiscoveryStore = { + onFound: () => {}, + getSnapshot: () => state, + subscribe: (onChange) => { + listeners.add(onChange); + stop ??= startDiscovery({ + candidates: key.split(" "), + probe: probeServeEndpoint, + onFound: (endpoint) => store.onFound(endpoint), + onChange: (next) => { + state = next; + for (const listener of listeners) listener(); + }, + }); + return () => { + listeners.delete(onChange); + if (listeners.size > 0) return; + stop?.(); + stop = null; + stores.delete(key); + }; + }, + }; + stores.set(key, store); + return store; +} + +const noSubscribe = () => () => {}; +const idleSnapshot = () => IDLE; +const lnaSnapshot = () => lnaState; + +/** + * Looks for a running `deco serve` (see `startDiscovery`). Only where Chrome + * would ask for permission first (a deployed Studio that doesn't have it + * yet), it waits for `start()`, a click on a button that names what Chrome + * is about to ask. + */ +export function useDecoServeDiscovery({ + candidates, + onFound, +}: { + candidates: readonly string[]; + onFound: (endpoint: string) => void; +}) { + const [asked, setAsked] = useState(false); + const access = useSyncExternalStore( + subscribeLocalNetworkAccess, + lnaSnapshot, + lnaSnapshot, + ); + const canProbe = + access === "granted" || + access === "unsupported" || + (access === "prompt" && asked); + const active = canProbe && candidates.length > 0; + const store = active ? discoveryStore(candidates.join(" ")) : null; + if (store) store.onFound = onFound; + const state = useSyncExternalStore( + store?.subscribe ?? noSubscribe, + store?.getSnapshot ?? idleSnapshot, + store?.getSnapshot ?? idleSnapshot, + ); + + return { + state, + access, + /** Whether the search waits for `start()`. */ + needsGesture: access === "prompt" && !asked, + start: () => setAsked(true), + }; +} diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index b6c11ed816..5ef15ccc32 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -20,6 +20,8 @@ export const thread = { "thread.branchPicker.localLabel": "Local", "thread.branchPicker.localHint": "Point preview and the CMS at your own dev server. Paste a public tunnel URL (e.g. ngrok, cloudflared) reachable from this browser.", + "thread.branchPicker.localServeConnected": + "Connected to deco serve on {host} (Blocks v8).", "thread.branchPicker.localUrlLabel": "Local tunnel URL", "thread.branchPicker.localUrlPlaceholder": "https://your-tunnel.example.com", "thread.branchPicker.localTurnOff": "Turn off", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 0635873637..c69237c9e0 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -22,6 +22,8 @@ export const thread = { "thread.branchPicker.localLabel": "Local", "thread.branchPicker.localHint": "Aponte o preview e o CMS para o seu próprio dev server. Cole uma URL de túnel público (ex.: ngrok, cloudflared) acessível a partir deste navegador.", + "thread.branchPicker.localServeConnected": + "Conectado ao deco serve em {host} (Blocks v8).", "thread.branchPicker.localUrlLabel": "URL do túnel local", "thread.branchPicker.localUrlPlaceholder": "https://seu-tunel.exemplo.com", "thread.branchPicker.localTurnOff": "Desligar", diff --git a/apps/web/src/routes/workspace/agent-site-editor.tsx b/apps/web/src/routes/workspace/agent-site-editor.tsx index 4716c21a2e..db76253fc3 100644 --- a/apps/web/src/routes/workspace/agent-site-editor.tsx +++ b/apps/web/src/routes/workspace/agent-site-editor.tsx @@ -15,6 +15,9 @@ import { shouldShowTerminalDrawer } from "@/layouts/main-panel-tabs/terminal-dra import { PreviewDrawerHost } from "@/layouts/main-panel-tabs/preview-drawer-host"; import { ChatLayout } from "@/components/chat-layout"; import { useVirtualMCP } from "@/sdk"; +import { DecoServeChip } from "@/components/sections-editor/deco-serve-chip"; +import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection"; +import { ContentVersionBadge } from "@/components/sections-editor/content-version-badge"; function SiteEditorActions() { const session = useOptionalChatTask(); @@ -23,27 +26,43 @@ function SiteEditorActions() { const runtime = useSessionRuntime(entity?.id).runtime; // Local mode edits are ephemeral (nothing to promote) → withhold publish. const { url: localPreviewUrl } = useLocalPreviewUrl(entity?.id); - if (!entity) return null; + // A connected `deco serve` edits the working tree: the developer commits. + // Outside a project (`/site-editor`) it is the only thing behind the editor. + const { connection: serveConnection } = useDecoServeConnection( + entity?.id ?? session?.virtualMcpId, + ); + const servingLocally = !!serveConnection; + const virtualMcpId = entity?.id ?? session?.virtualMcpId; + if (!virtualMcpId || (!entity && !servingLocally)) return null; return ( <> -
- -
- {!localPreviewUrl && agentShowsRepositoryHeaderActions(entity) && ( - <> - -
- {runtime === "cms" ? ( - - ) : ( - - )} -
- + + {servingLocally && ( + + )} + {entity && ( +
+ +
)} + {entity && + !localPreviewUrl && + !servingLocally && + agentShowsRepositoryHeaderActions(entity) && ( + <> + +
+ {runtime === "cms" ? ( + + ) : ( + + )} +
+ + )} ); } @@ -55,8 +74,13 @@ function SiteEditorDrawer() { const activeTabId = useActivePanelTabId(); const sessionRuntime = useSessionRuntime(entity?.id).runtime; const { url: localPreviewUrl } = useLocalPreviewUrl(entity?.id); + const { connection: serveConnection } = useDecoServeConnection( + entity?.id ?? session?.virtualMcpId, + ); + const servingLocally = !!serveConnection; const showDrawer = !localPreviewUrl && + !servingLocally && shouldShowTerminalDrawer({ hasClonableSource: agentHasClonableSource(entity?.metadata) || @@ -67,7 +91,8 @@ function SiteEditorDrawer() { return showDrawer ? : null; } -/** Preview, Content and Code share a route-owned topbar and runtime context. */ +/** Preview, Content and Code share a route-owned topbar and runtime context. + * The account-less `/site-editor` mounts this same app over a `deco serve`. */ export default function SiteEditorRoute() { return ( Date: Fri, 9 Oct 2026 12:34:17 -0300 Subject: [PATCH 2/2] fix(web): editor and draft failures show plain words, the error behind Details Save as global block, delete rule, apply rule and rename variant no longer put the server's error text in the toast headline; neither does discarding or renaming a draft. The site tour calls drafts drafts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/sections-editor.tsx | 64 ++++++++----------- .../sections-editor/site-editor-toast.tsx | 8 ++- .../thread/repository/branch-picker.tsx | 10 ++- apps/web/src/i18n/en/layout-tour.ts | 4 +- apps/web/src/i18n/en/thread.ts | 2 +- apps/web/src/i18n/pt-br/layout-tour.ts | 4 +- apps/web/src/i18n/pt-br/thread.ts | 2 +- 7 files changed, 46 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/sections-editor/sections-editor.tsx b/apps/web/src/components/sections-editor/sections-editor.tsx index 276fe5388b..11503d51c1 100644 --- a/apps/web/src/components/sections-editor/sections-editor.tsx +++ b/apps/web/src/components/sections-editor/sections-editor.tsx @@ -48,6 +48,7 @@ import { PageSeoForm } from "./page-seo-form"; import { extractMatcherGlobals, extractMatchers } from "./matcher-picker"; import { PageVariantTabs, VariantTabIcon } from "./page-variant-tabs"; import { MakeReusableModal } from "./make-reusable-modal"; +import { errorDetail, errorDetailsDescription } from "./site-editor-toast"; import { AddSectionModal } from "./add-section-modal"; import { useSectionPreviewBase } from "./use-section-preview-base"; import { useContentBackend } from "./use-content-backend"; @@ -1470,11 +1471,9 @@ export function SectionsEditor({ ); onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.failedToSaveGlobalBlock"), - ); + toast.error(t("sectionsEditor.sectionsEditor.failedToSaveGlobalBlock"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } }; @@ -1529,9 +1528,10 @@ export function SectionsEditor({ await deleteBlock.mutateAsync({ blockKey }); } catch (err) { toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.couldNotDeleteMatcherBlock"), + t("sectionsEditor.sectionsEditor.couldNotDeleteMatcherBlock"), + { + description: errorDetailsDescription(t, errorDetail(err)), + }, ); } }; @@ -1682,11 +1682,9 @@ export function SectionsEditor({ } onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), - ); + toast.error(t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } }; @@ -1874,11 +1872,9 @@ export function SectionsEditor({ } onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), - ); + toast.error(t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } }; @@ -2459,11 +2455,9 @@ export function SectionsEditor({ ); onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.failedToRenameVariant"), - ); + toast.error(t("sectionsEditor.sectionsEditor.failedToRenameVariant"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } finally { setRenameVariantPending(false); } @@ -2621,11 +2615,9 @@ export function SectionsEditor({ ); onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.failedToRenameVariant"), - ); + toast.error(t("sectionsEditor.sectionsEditor.failedToRenameVariant"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } finally { setRenameVariantPending(false); } @@ -2754,11 +2746,9 @@ export function SectionsEditor({ ); onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.failedToRenameVariant"), - ); + toast.error(t("sectionsEditor.sectionsEditor.failedToRenameVariant"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } finally { setRenameVariantPending(false); } @@ -2809,11 +2799,9 @@ export function SectionsEditor({ } onSaved?.(); } catch (err) { - toast.error( - err instanceof Error - ? err.message - : t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), - ); + toast.error(t("sectionsEditor.sectionsEditor.couldNotApplySavedRule"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); } }; diff --git a/apps/web/src/components/sections-editor/site-editor-toast.tsx b/apps/web/src/components/sections-editor/site-editor-toast.tsx index 5233c03b09..818f477b56 100644 --- a/apps/web/src/components/sections-editor/site-editor-toast.tsx +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -29,7 +29,7 @@ function ErrorDetails({ label, detail }: { label: string; detail: string }) { } /** A toast `description` for `detail`, or nothing when there is none. */ -function errorDetailsDescription(t: TFunction, detail: string | null) { +export function errorDetailsDescription(t: TFunction, detail: string | null) { return detail ? ( ) : undefined; @@ -46,3 +46,9 @@ export function toastSaveError( description: errorDetailsDescription(t, saveErrorDetail(error, source)), }); } + +/** The developer detail of a failed request, for Details. */ +export function errorDetail(error: unknown): string | null { + if (error instanceof Error) return error.message || null; + return error == null ? null : String(error); +} diff --git a/apps/web/src/components/thread/repository/branch-picker.tsx b/apps/web/src/components/thread/repository/branch-picker.tsx index 9124831713..2c5ca311b4 100644 --- a/apps/web/src/components/thread/repository/branch-picker.tsx +++ b/apps/web/src/components/thread/repository/branch-picker.tsx @@ -1,4 +1,8 @@ import { type Ref, useRef, useState } from "react"; +import { + errorDetail, + errorDetailsDescription, +} from "@/components/sections-editor/site-editor-toast.tsx"; import type { RepoToolTarget } from "@/lib/repository-binding.ts"; import { LAYOUT_TOUR_ANCHORS } from "@/components/layout-tour/anchors"; import { Button } from "@decocms/ui/components/button.tsx"; @@ -213,9 +217,9 @@ export function BranchPicker({ // A failed release write reverts the row silently otherwise — surface it. const reportReleaseError = (err: unknown) => { - toast.error( - err instanceof Error ? err.message : t("thread.branchPicker.saveError"), - ); + toast.error(t("thread.branchPicker.saveError"), { + description: errorDetailsDescription(t, errorDetail(err)), + }); }; // Save + activate a Local tunnel URL, or turn it off when cleared. diff --git a/apps/web/src/i18n/en/layout-tour.ts b/apps/web/src/i18n/en/layout-tour.ts index eafa1274c8..28909c8706 100644 --- a/apps/web/src/i18n/en/layout-tour.ts +++ b/apps/web/src/i18n/en/layout-tour.ts @@ -29,9 +29,9 @@ export const layoutTour = { "layoutTour.surfaceTabs.title": "Preview and Content, side by side", "layoutTour.surfaceTabs.description": "Preview renders the page as visitors see it; Content opens the blocks behind it. Switch between them without leaving the page you are on.", - "layoutTour.branchPicker.title": "Work on a branch", + "layoutTour.branchPicker.title": "Your drafts", "layoutTour.branchPicker.description": - "Changes land on the branch named here. Switch it to work somewhere else, and publish when the work is ready.", + "Changes go to the draft named here. Switch drafts to work on something else, and publish when it's ready.", "layoutTour.automations.title": "Put the work on a schedule", "layoutTour.automations.description": "Automations run this project on a trigger or a schedule, so recurring work happens without anyone starting it.", diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 5ef15ccc32..3b40af41e5 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -31,7 +31,7 @@ export const thread = { "Version limit reached ({max}). Delete a version to create a new one.", "thread.branchPicker.rename": "Rename", "thread.branchPicker.save": "Save", - "thread.branchPicker.saveError": "Couldn't save the version. Try again.", + "thread.branchPicker.saveError": "Couldn't update your draft. Try again.", "thread.branchPicker.selectVersion": "Select a version", "thread.branchPicker.newChatHint": "This chat's branch is fixed. Picking or creating a branch opens a new chat on it.", diff --git a/apps/web/src/i18n/pt-br/layout-tour.ts b/apps/web/src/i18n/pt-br/layout-tour.ts index 4dace25278..2451585455 100644 --- a/apps/web/src/i18n/pt-br/layout-tour.ts +++ b/apps/web/src/i18n/pt-br/layout-tour.ts @@ -30,9 +30,9 @@ export const layoutTour = { "layoutTour.surfaceTabs.title": "Preview e Conteúdo, lado a lado", "layoutTour.surfaceTabs.description": "O Preview mostra a página como os visitantes veem; Conteúdo abre os blocos por trás dela. Alterne entre os dois sem sair da página em que você está.", - "layoutTour.branchPicker.title": "Trabalhe em uma branch", + "layoutTour.branchPicker.title": "Seus rascunhos", "layoutTour.branchPicker.description": - "As mudanças vão para a branch indicada aqui. Troque para trabalhar em outra e publique quando o trabalho estiver pronto.", + "As alterações vão para o rascunho indicado aqui. Troque de rascunho para trabalhar em outra coisa e publique quando estiver pronto.", "layoutTour.automations.title": "Coloque o trabalho no automático", "layoutTour.automations.description": "Automações rodam este projeto por gatilho ou agendamento, então o trabalho recorrente acontece sem ninguém precisar iniciar.", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index c69237c9e0..2b18162720 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -34,7 +34,7 @@ export const thread = { "thread.branchPicker.rename": "Renomear", "thread.branchPicker.save": "Salvar", "thread.branchPicker.saveError": - "Não foi possível salvar a versão. Tente novamente.", + "Não foi possível atualizar seu rascunho. Tente de novo.", "thread.branchPicker.selectVersion": "Selecione uma versão", "thread.branchPicker.newChatHint": "A branch deste chat é fixa. Escolher ou criar uma branch abre um chat novo nela.",