- {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/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 b6c11ed816..3b40af41e5 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",
@@ -29,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 0635873637..2b18162720 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",
@@ -32,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.",
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 (