From ffdec71ff04aeda1683df92d705743865d1ebd4e Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:09:53 -0300 Subject: [PATCH] feat(web): account-less /site-editor over deco serve Adds the account-less `/site-editor` route over `deco serve` (no org, no project, no sign-in), with the layout pieces it needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/web/src/components/chat-layout/index.tsx | 45 +-- apps/web/src/components/chat/chat-context.tsx | 12 + apps/web/src/components/chat/context.tsx | 1 + apps/web/src/components/layout/index.tsx | 10 +- apps/web/src/hooks/use-preferences.ts | 8 +- apps/web/src/hooks/use-route-default-main.ts | 3 + apps/web/src/layouts/route-page-header.tsx | 2 +- apps/web/src/router.tsx | 40 +++ apps/web/src/routes/site-editor.tsx | 337 ++++++++++++++++++ knip.jsonc | 6 +- .../shared/src/organization-slugs.test.ts | 1 + packages/shared/src/organization-slugs.ts | 1 + 12 files changed, 437 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/routes/site-editor.tsx diff --git a/apps/web/src/components/chat-layout/index.tsx b/apps/web/src/components/chat-layout/index.tsx index dfc96c09a1..247d20137f 100644 --- a/apps/web/src/components/chat-layout/index.tsx +++ b/apps/web/src/components/chat-layout/index.tsx @@ -35,6 +35,9 @@ interface ChatLayoutProps extends ChatLayoutState, ChatLayoutActions { contentKey: string; contentNavigation?: ReactNode; contentActions?: ReactNode; + /** No chat behind this layout (the account-less `/site-editor`): the thread + * stays closed and nothing offers to open it. */ + threadless?: boolean; } interface ChatLayoutContextValue extends Omit { @@ -52,7 +55,7 @@ export function useChatLayout(): ChatLayoutState & ChatLayoutActions { /** Null outside a ChatLayout (e.g. the settings route), rather than throwing — * for callers that render in both. */ export function useOptionalChatLayout(): - | (ChatLayoutState & ChatLayoutActions) + | (ChatLayoutState & ChatLayoutActions & { threadless?: boolean }) | null { return use(ChatLayoutContext); } @@ -107,25 +110,27 @@ function ChatLayoutRoot({ children, ...layout }: ChatLayoutProps) { return ( - { - if (isMobile) { - void navigate({ - to: ".", - search: (prev) => ({ - ...prev, - ...mobileSurfaceSearch( - value.mobileSurface === "chat" ? "main" : "chat", - ), - }), - replace: true, - }); - } else { - layout.toggleThread(); - } - }} - /> + {!layout.threadless && ( + { + if (isMobile) { + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + ...mobileSurfaceSearch( + value.mobileSurface === "chat" ? "main" : "chat", + ), + }), + replace: true, + }); + } else { + layout.toggleThread(); + } + }} + /> + )} {isMobile ? (
) { + return {children}; +} + /** * Blanks the stream and task contexts for a subtree. * diff --git a/apps/web/src/components/chat/context.tsx b/apps/web/src/components/chat/context.tsx index ecaf637cc5..cbf9746da6 100644 --- a/apps/web/src/components/chat/context.tsx +++ b/apps/web/src/components/chat/context.tsx @@ -11,6 +11,7 @@ export { ChatContextProvider as ChatProvider, ChatPrefsProvider, ChatStreamValueProvider, + ChatTaskValueProvider, DetachedChatContext, ActiveTaskProvider, ThreadlessChatProvider, diff --git a/apps/web/src/components/layout/index.tsx b/apps/web/src/components/layout/index.tsx index beadceffe7..e9dd249b7c 100644 --- a/apps/web/src/components/layout/index.tsx +++ b/apps/web/src/components/layout/index.tsx @@ -37,9 +37,13 @@ function useLayout() { function LayoutRoot({ children, notice, + outsideOrg, }: { children: ReactNode; notice?: ReactNode; + /** Outside any org (`/site-editor`): no org apps to record, and the org + * rail only when `rail` (a signed-in person, whose orgs it lists). */ + outsideOrg?: { rail: boolean }; }) { const isMobile = useIsMobile(); @@ -56,8 +60,10 @@ function LayoutRoot({
{notice}
- {projectFirstNav && } - {!isMobile && projectFirstNav && } + {projectFirstNav && !outsideOrg && } + {!isMobile && projectFirstNav && (outsideOrg?.rail ?? true) && ( + + )} rootRoute, + path: "/site-editor", + staticData: { pageTitle: "sidebar.projectNav.siteEditor" }, + component: lazyRouteComponent(() => import("./routes/site-editor.tsx")), +}); + +const siteEditorPreviewRoute = createRoute({ + getParentRoute: () => siteEditorRoute, + path: "/", + staticData: { + mainView: "site-editor", + siteEditorView: "preview", + local: true, + }, + component: lazyRouteComponent( + () => import("./routes/site-editor.tsx"), + "SiteEditorPreview", + ), +}); + +const siteEditorContentRoute = createRoute({ + getParentRoute: () => siteEditorRoute, + path: "/content", + staticData: { mainView: "content", siteEditorView: "content", local: true }, + component: lazyRouteComponent( + () => import("./routes/site-editor.tsx"), + "SiteEditorContent", + ), +}); + // Public report, readable without a session — hence outside the org shell. const reportRoute = createRoute({ getParentRoute: () => rootRoute, @@ -1612,6 +1651,7 @@ const routeTree = rootRoute.addChildren([ reportsOnboardingRoute, legacyCommerceOnboardingRoute, chooseEditorRoute, + siteEditorRoute.addChildren([siteEditorPreviewRoute, siteEditorContentRoute]), reportRoute, loginRoute, cliAuthSuccessRoute, diff --git a/apps/web/src/routes/site-editor.tsx b/apps/web/src/routes/site-editor.tsx new file mode 100644 index 0000000000..da6052d391 --- /dev/null +++ b/apps/web/src/routes/site-editor.tsx @@ -0,0 +1,337 @@ +/** + * `/site-editor#endpoint=…` — the site editor over the `deco serve` + * on this machine (the link the Blocks CLI prints), without a project. + * + * It is not gated, and it always renders in the New Layout, whatever this + * person's preference says (`ForceProjectFirstNav`, scoped to this route). It + * is the Site Editor APP, the same one a project launches + * (`routes/workspace/agent-site-editor.tsx` and its Preview/Content tabs), in + * the same app takeover: the org rail (signed in + * only, as there are no orgs to list otherwise), no sidebar, the app full + * screen. What needs Studio's hosting is left out: chat, GitHub, drafts, + * publishing and Code. There is no org, so no recent app is recorded; the + * rail still marks the Site Editor as the open app (`staticData.local`). + * + * One rule (`LocalServeSwitch`): it looks for `deco serve` at the link's + * endpoint, or else the last one used and the default port + * (`serveCandidates`). If it answers, the editor opens; if not, the guide + * (`SiteEditorGuide`) shows and keeps looking, and the editor opens as soon + * as it answers. When the server stops answering while editing, the guide + * comes back until it does. There is no Disconnect. + * + * `deco serve` has no token: the link carries only its endpoint (a `token=` + * in an older link is ignored). The endpoint leaves the fragment at once and + * is remembered in this browser. Only loopback servers are accepted + * (`parseConnectFragment`). + * + * The editor expects a project and a chat task: it gets a placeholder project, + * in an org with no id (which makes the Studio-backed reads skip themselves), + * and a task with no thread. The connection reaches every surface through + * `TabDecoServeConnectionContext`. + */ + +import { useEffect, useState, type ReactNode } from "react"; +import { Link, useRouterState } from "@tanstack/react-router"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { + ChatTaskValueProvider, + type ChatTaskContextValue, +} from "@/components/chat/context"; +import { ChatLayout } from "@/components/chat-layout"; +import { Layout } from "@/components/layout"; +import { Page } from "@/components/page"; +import { BlocksPreviewWorkspaceProvider } from "@/components/sandbox/blocks/blocks-preview-workspace-context"; +import { ConnectCentered as Centered } from "@/components/sections-editor/deco-serve-chip"; +import { + endpointHost, + parseConnectFragment, + readLastConnection, + saveLastConnection, + serveCandidates, +} from "@/components/sections-editor/deco-serve-connection"; +import { + DocsLinks, + RichCode, + serveProblemCopy, +} from "@/components/sections-editor/deco-serve-notices"; +import { + isServeLost, + LocalServeSwitch, +} from "@/components/sections-editor/site-editor-guide"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend"; +import { + TabDecoServeConnectionContext, + useDecoServeConnection, +} from "@/hooks/use-deco-serve-connection"; +import { ForceProjectFirstNav } from "@/hooks/use-preferences"; +import { useT } from "@/i18n/use-t.ts"; +import { ContentTab } from "@/layouts/main-panel-tabs/content-tab"; +import { PreviewTab } from "@/layouts/main-panel-tabs/preview-tab"; +import { TabIconGlyph } from "@/layouts/main-panel-tabs/tab-icon-glyph"; +import { resolveTabIcon } from "@/layouts/main-panel-tabs/resolve-tab-icon"; +import { authClient } from "@/lib/auth-client"; +import SiteEditorApp from "@/routes/workspace/agent-site-editor"; +import { ProjectContextProvider } from "@/sdk"; + +/** Stands in for the project id every editor hook is keyed by. */ +const LOCAL_PROJECT_ID = "deco-serve"; +/** Stands in for the branch: `deco serve` edits the working tree. */ +const LOCAL_BRANCH = "working-tree"; + +/** No id: no Studio org behind this editor (see the file comment). */ +const NO_ORG = { id: "", slug: "", name: "", logo: null }; +const LOCAL_PROJECT = { id: LOCAL_PROJECT_ID, slug: LOCAL_PROJECT_ID }; + +/** No thread and no chat: nothing here opens or creates one. */ +const LOCAL_TASK: ChatTaskContextValue = { + virtualMcpId: LOCAL_PROJECT_ID, + taskId: null, + openTask: () => {}, + createTask: () => "", + createTaskWithMessage: () => {}, + activeTask: null, + isThreadLocked: false, + lockedHarness: null, + lockedBranch: null, + currentBranch: LOCAL_BRANCH, + setCurrentTaskBranch: () => {}, +}; + +interface RouteStart { + /** Where to look for `deco serve` (see `serveCandidates`). */ + candidates: string[]; + /** The page was opened with a link that isn't a usable one. */ + invalidLink: boolean; +} + +/** + * Takes the link's endpoint out of the address bar (and remembers it): it is + * the only place looked at. With no usable link, the last one used and the + * default port. + */ +function takeStartFromUrl(): RouteStart { + const hash = window.location.hash; + const hasLink = new URLSearchParams(hash.replace(/^#/, "")).has("endpoint"); + const fromLink = hasLink ? parseConnectFragment(hash) : null; + if (hasLink) { + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + if (fromLink) saveLastConnection(fromLink); + return { + candidates: serveCandidates(fromLink, readLastConnection()), + invalidLink: hasLink && !fromLink, + }; +} + +/** The app's own Preview/Content tabs, as the project tab bar draws them + * (`MainPanelTabsBar` reads a project's views, and there is none here). */ +const TABS = [ + { id: "site-editor", to: "/site-editor", label: "preview" }, + { id: "content", to: "/site-editor/content", label: "content" }, +] as const; + +function SiteEditorTabs() { + const t = useT(); + const view = useRouterState({ + select: (state) => state.matches.at(-1)?.staticData.siteEditorView, + }); + return ( + + {TABS.map((tab) => ( + + + + {t(`common.mainPanelTabs.${tab.label}`)} + + + ))} + + ); +} + +/** A connection problem, centered in the editor's frame, scrolling when tall. */ +function GateFrame({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +/** + * Holds the editor until the local server answers its probe, and explains + * why when it answered but can't be used: out of date, another version, + * another program on the port, or an error. A server + * that stops answering takes the route back to its guide (`ServeLostWatcher`). + */ +function BackendGate({ children }: { children: ReactNode }) { + const t = useT(); + const { connection } = useDecoServeConnection(LOCAL_PROJECT_ID); + const backend = useContentBackend(LOCAL_PROJECT_ID, LOCAL_BRANCH); + const host = connection ? endpointHost(connection.endpoint) : ""; + + // With no schema yet, the editor still opens: Content shows every block as + // plain fields with a banner on how to generate the forms. + if (backend.kind === "protocol") return children; + if (backend.kind !== "unavailable" || isServeLost(backend)) { + return ( + + +

+ {t("decoServe.connect.reaching", { host })} +

+
+ ); + } + // It answered, but can't be used; the probe keeps polling on its own. + const { title, body } = serveProblemCopy( + t, + backend.problem ?? { reason: "not-answering" }, + host, + ); + return ( + +
+

{title}

+

+ +

+
+ +
+ ); +} + +/** Calls `onLost` when the editor's server stops answering. */ +function ServeLostWatcher({ onLost }: { onLost: () => void }) { + const backend = useContentBackend(LOCAL_PROJECT_ID, LOCAL_BRANCH); + const lost = isServeLost(backend); + // The probe is an outside system: its failure hands the route back to the + // guide, which owns the search from there. + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + if (lost) onLost(); + }, [lost, onLost]); + return null; +} + +/** Chat stays closed: there is no thread outside a project. */ +const NO_CHAT = { + threadOpen: false, + contentOpen: true, + threadVisibilityExplicit: false, + toggleThread: () => {}, + toggleContent: () => {}, +}; + +/** The app takeover a project's launched app gets, around the same app. */ +function LocalSiteEditor() { + const { data: session } = authClient.useSession(); + const view = useRouterState({ + select: (state) => state.matches.at(-1)?.staticData.siteEditorView, + }); + return ( + + + } + > + {null} + + + + + ); +} + +/** Before a connection: the guide, in the same app shell as the editor. */ +function LocalSiteEditorGuide({ children }: { children: ReactNode }) { + const { data: session } = authClient.useSession(); + return ( + + + + {null} + {children} + + + + ); +} + +/** Nothing on `/site-editor` changes the server: it follows the rule above. */ +const noop = () => {}; +const NOT_CONNECTED = { connection: null, set: noop, clear: noop }; + +export default function SiteEditorRoute() { + const [start] = useState(takeStartFromUrl); + return ( + + + ( + + {guide} + + )} + editor={(connection, onLost) => ( + + + + + + + + + )} + /> + + + ); +} + +/** The Preview tab: the app `deco serve --preview` names, with Blocks. */ +export function SiteEditorPreview() { + return ( + + + + ); +} + +/** The Content tab. */ +export function SiteEditorContent() { + return ( + + + + ); +} diff --git a/knip.jsonc b/knip.jsonc index 5996d8a27e..648d876dea 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -76,10 +76,6 @@ "ignoreDependencies": ["@types/*"], // TODO(@camudo): Remove this config once I fix all unused type exports "ignoreIssues": { - "**/*": ["types"], - // Temporary while the v8 stack lands: each export below gets its - // consumer in a later PR of the stack, which removes its entry. - "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"], - "apps/web/src/components/sections-editor/deco-serve-chip.tsx": ["exports"] + "**/*": ["types"] } } diff --git a/packages/shared/src/organization-slugs.test.ts b/packages/shared/src/organization-slugs.test.ts index 8761917b8c..b50da4c109 100644 --- a/packages/shared/src/organization-slugs.test.ts +++ b/packages/shared/src/organization-slugs.test.ts @@ -28,6 +28,7 @@ describe("reserved organization slugs", () => { "report", "reports-onboarding", "reset-password", + "site-editor", ]); expect(isReservedOrganizationSlug(" Report ")).toBe(true); expect(isReservedOrganizationSlug("API")).toBe(true); diff --git a/packages/shared/src/organization-slugs.ts b/packages/shared/src/organization-slugs.ts index 4340b92df1..f9eb2686f2 100644 --- a/packages/shared/src/organization-slugs.ts +++ b/packages/shared/src/organization-slugs.ts @@ -28,6 +28,7 @@ export const RESERVED_ORGANIZATION_SLUGS: ReadonlySet = new Set([ "report", "reports-onboarding", "reset-password", + "site-editor", ]); export function isReservedOrganizationSlug(slug: unknown): boolean {