From c9b73735edcc14366d34681f1dd1858a9e00f256 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:11:01 -0300 Subject: [PATCH 1/4] feat(web): Releases tab for hosted v8 projects Adds the Releases tab for hosted v8 projects: timeline, Make current, schema-mismatch warning. The `releases` view appears only when there is a source. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../components/projects/project-apps.test.ts | 1 + .../src/components/projects/project-apps.tsx | 11 +- .../src/components/sidebar/project-nav.tsx | 5 + apps/web/src/hooks/use-destination-route.ts | 1 + apps/web/src/i18n/en/common.ts | 1 + apps/web/src/i18n/en/index.ts | 2 + apps/web/src/i18n/en/projects.ts | 2 + apps/web/src/i18n/en/releases.ts | 21 ++ apps/web/src/i18n/pt-br/common.ts | 1 + apps/web/src/i18n/pt-br/index.ts | 2 + apps/web/src/i18n/pt-br/projects.ts | 2 + apps/web/src/i18n/pt-br/releases.ts | 24 ++ apps/web/src/layouts/legacy-main-redirect.tsx | 10 + .../project-sidebar-views.test.ts | 9 +- .../main-panel-tabs/project-sidebar-views.ts | 15 +- .../layouts/main-panel-tabs/releases-api.ts | 106 ++++++++ .../layouts/main-panel-tabs/releases-tab.tsx | 243 ++++++++++++++++++ .../main-panel-tabs/resolve-tab-icon.ts | 3 + .../src/layouts/main-panel-tabs/tab-route.ts | 4 + .../main-panel-tabs/use-main-panel-tabs.ts | 1 + .../use-project-native-view-presence.ts | 12 +- apps/web/src/router.tsx | 16 ++ .../src/routes/workspace/agent-releases.tsx | 21 ++ .../virtual-mcp/settings/use-project-views.ts | 1 + .../virtual-mcp/settings/view-row-actions.tsx | 2 + packages/shared/src/sdk/types/virtual-mcp.ts | 1 + packages/shared/src/tools/tool-io.ts | 18 ++ 27 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/i18n/en/releases.ts create mode 100644 apps/web/src/i18n/pt-br/releases.ts create mode 100644 apps/web/src/layouts/main-panel-tabs/releases-api.ts create mode 100644 apps/web/src/layouts/main-panel-tabs/releases-tab.tsx create mode 100644 apps/web/src/routes/workspace/agent-releases.tsx diff --git a/apps/web/src/components/projects/project-apps.test.ts b/apps/web/src/components/projects/project-apps.test.ts index 8f9b510e0b..8ff13ae61f 100644 --- a/apps/web/src/components/projects/project-apps.test.ts +++ b/apps/web/src/components/projects/project-apps.test.ts @@ -78,6 +78,7 @@ describe("launchableApps", () => { analytics: false, cdn: false, experiments: false, + releases: false, }; expect( launchableApps( diff --git a/apps/web/src/components/projects/project-apps.tsx b/apps/web/src/components/projects/project-apps.tsx index 53928ce776..e9e676798e 100644 --- a/apps/web/src/components/projects/project-apps.tsx +++ b/apps/web/src/components/projects/project-apps.tsx @@ -6,6 +6,7 @@ import { Link } from "@tanstack/react-router"; import { + ClockRewind, BarChartSquare02, BezierCurve02, FileSearch02, @@ -79,6 +80,13 @@ const APPS: Record< to: PROJECT_ROUTE.assets, tone: "bg-chart-2/15 text-chart-2", }, + releases: { + labelKey: "projects.apps.releases", + captionKey: "projects.apps.releasesCaption", + Icon: ClockRewind, + to: PROJECT_ROUTE.releases, + tone: "bg-chart-3/15 text-chart-3", + }, hosting: { labelKey: "projects.apps.hosting", captionKey: "projects.apps.hostingCaption", @@ -256,7 +264,8 @@ function ProjectAppTilesBody({ const apps = launchableApps(project, native ?? undefined).filter( (id) => /* Both need a repo, as in the sidebar's presence rules. */ - ((id !== "site-editor" && id !== "experiments") || hasSource) && + ((id !== "site-editor" && id !== "experiments" && id !== "releases") || + hasSource) && /* Every project has these two, so across an org they bury the apps someone actually chose. They stay on each project's own screen. */ !(showProject && ORG_HIDDEN_APPS.has(id)), diff --git a/apps/web/src/components/sidebar/project-nav.tsx b/apps/web/src/components/sidebar/project-nav.tsx index 2e0e52e36e..31338812d6 100644 --- a/apps/web/src/components/sidebar/project-nav.tsx +++ b/apps/web/src/components/sidebar/project-nav.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { + ClockRewind, BarChartSquare02, Beaker02, CheckDone01, @@ -115,6 +116,10 @@ export function ProjectNav({ onNavigate }: { onNavigate?: () => void }) { label: t("common.mainPanelTabs.assets"), icon: , }, + releases: { + label: t("common.mainPanelTabs.releases"), + icon: , + }, hosting: { label: t("common.mainPanelTabs.hosting"), icon: , diff --git a/apps/web/src/hooks/use-destination-route.ts b/apps/web/src/hooks/use-destination-route.ts index 139c7ecde3..b6173bc830 100644 --- a/apps/web/src/hooks/use-destination-route.ts +++ b/apps/web/src/hooks/use-destination-route.ts @@ -50,6 +50,7 @@ export const PROJECT_ROUTE = { connectSources: "/$org/projects/$agentId/connect-sources", settings: "/$org/projects/$agentId/settings", assets: "/$org/projects/$agentId/assets", + releases: "/$org/projects/$agentId/releases", hosting: "/$org/projects/$agentId/hosting", e2e: "/$org/projects/$agentId/e2e", analytics: "/$org/projects/$agentId/analytics", diff --git a/apps/web/src/i18n/en/common.ts b/apps/web/src/i18n/en/common.ts index 3475966356..2e128554af 100644 --- a/apps/web/src/i18n/en/common.ts +++ b/apps/web/src/i18n/en/common.ts @@ -269,6 +269,7 @@ export const common = { "common.mainPanelTabs.code": "Code", "common.mainPanelTabs.content": "Content", "common.mainPanelTabs.assets": "Assets", + "common.mainPanelTabs.releases": "Releases", "common.mainPanelTabs.hosting": "Hosting", "common.mainPanelTabs.e2e": "E2E", "common.mainPanelTabs.analytics": "Deco Analytics", diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 0b07309de2..11ccf2552e 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -30,6 +30,7 @@ import { discover } from "./discover.ts"; import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; +import { releases } from "./releases.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -65,6 +66,7 @@ export const en = { ...monitoring, ...mainPanelTabs, ...experiments, + ...releases, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/en/projects.ts b/apps/web/src/i18n/en/projects.ts index b8d6498959..44ac9b7e53 100644 --- a/apps/web/src/i18n/en/projects.ts +++ b/apps/web/src/i18n/en/projects.ts @@ -76,6 +76,8 @@ export const projects = { "projects.apps.siteEditorCaption": "Pages, sections and content", "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Images and files the site uses", + "projects.apps.releases": "Releases", + "projects.apps.releasesCaption": "What the site serves, and rollbacks", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domains and environments", "projects.apps.e2e": "End-to-end tests", diff --git a/apps/web/src/i18n/en/releases.ts b/apps/web/src/i18n/en/releases.ts new file mode 100644 index 0000000000..91daefcf0a --- /dev/null +++ b/apps/web/src/i18n/en/releases.ts @@ -0,0 +1,21 @@ +export const releases = { + "releases.title": "Releases", + "releases.subtitle": + "Every change merged to main, newest first. A Publish also creates a release on the CDN; Make current switches the CDN to any release.", + "releases.loadFailed": "Couldn't load releases", + "releases.currentOnCdn": "Current on the CDN:", + "releases.madeCurrentAgo": "made current {when}", + "releases.nothingOnCdn": "Nothing on the CDN yet", + "releases.current": "Current", + "releases.actions": "Release actions", + "releases.makeCurrent": "Make current", + "releases.makeCurrentFailed": "Making it current failed. Try again.", + "releases.makeCurrentTitle": "Make {sha} current?", + "releases.makeCurrentBody": + "The CDN serves this release from now on. Git doesn't change, and the next Publish makes its own release current.", + "releases.schemaMismatchBody": + "This release was made with a different schema than main's. Sites built from main keep their current content until the schemas match again.", + "releases.madeCurrent": "{sha} is now current", + "releases.loadMore": "Load more", + "releases.cancel": "Cancel", +}; diff --git a/apps/web/src/i18n/pt-br/common.ts b/apps/web/src/i18n/pt-br/common.ts index 470d105ed2..0a73f49c6b 100644 --- a/apps/web/src/i18n/pt-br/common.ts +++ b/apps/web/src/i18n/pt-br/common.ts @@ -280,6 +280,7 @@ export const common = { "common.mainPanelTabs.code": "Código", "common.mainPanelTabs.content": "Conteúdo", "common.mainPanelTabs.assets": "Assets", + "common.mainPanelTabs.releases": "Versões", "common.mainPanelTabs.hosting": "Hospedagem", "common.mainPanelTabs.e2e": "E2E", "common.mainPanelTabs.analytics": "Deco Analytics", diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 52609d792f..4a1f47999b 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -30,6 +30,7 @@ import { discover } from "./discover.ts"; import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; +import { releases } from "./releases.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -63,6 +64,7 @@ export const ptBR = { ...monitoring, ...mainPanelTabs, ...experiments, + ...releases, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/pt-br/projects.ts b/apps/web/src/i18n/pt-br/projects.ts index e89387942e..228222e0d3 100644 --- a/apps/web/src/i18n/pt-br/projects.ts +++ b/apps/web/src/i18n/pt-br/projects.ts @@ -70,6 +70,8 @@ export const projects = { "projects.apps.siteEditorCaption": "Páginas, seções e conteúdo", "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Imagens e arquivos que o site usa", + "projects.apps.releases": "Versões", + "projects.apps.releasesCaption": "O que o site serve, e reversões", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domínios e ambientes", "projects.apps.e2e": "Testes ponta a ponta", diff --git a/apps/web/src/i18n/pt-br/releases.ts b/apps/web/src/i18n/pt-br/releases.ts new file mode 100644 index 0000000000..729faac8fc --- /dev/null +++ b/apps/web/src/i18n/pt-br/releases.ts @@ -0,0 +1,24 @@ +import type { releases as enReleases } from "../en/releases.ts"; + +export const releases = { + "releases.title": "Versões", + "releases.subtitle": + "Todas as alterações mescladas na main, da mais recente para a mais antiga. Publicar também cria uma versão na CDN; Tornar atual faz a CDN servir qualquer versão.", + "releases.loadFailed": "Não foi possível carregar as versões", + "releases.currentOnCdn": "Atual na CDN:", + "releases.madeCurrentAgo": "tornada atual {when}", + "releases.nothingOnCdn": "Nada na CDN ainda", + "releases.current": "Atual", + "releases.actions": "Ações da versão", + "releases.makeCurrent": "Tornar atual", + "releases.makeCurrentFailed": + "Não foi possível torná-la atual. Tente de novo.", + "releases.makeCurrentTitle": "Tornar {sha} a versão atual?", + "releases.makeCurrentBody": + "A CDN passa a servir esta versão. O git não muda, e a próxima publicação torna a sua própria versão atual.", + "releases.schemaMismatchBody": + "Esta versão foi criada com um schema diferente do da main. Sites gerados a partir da main mantêm o conteúdo atual até os schemas voltarem a coincidir.", + "releases.madeCurrent": "{sha} agora é a versão atual", + "releases.loadMore": "Carregar mais", + "releases.cancel": "Cancelar", +} satisfies Record; diff --git a/apps/web/src/layouts/legacy-main-redirect.tsx b/apps/web/src/layouts/legacy-main-redirect.tsx index f8641379bd..89d8baf1c9 100644 --- a/apps/web/src/layouts/legacy-main-redirect.tsx +++ b/apps/web/src/layouts/legacy-main-redirect.tsx @@ -161,6 +161,16 @@ export function LegacyCanonicalNavigate({ replace /> ); + case PROJECT_ROUTE.releases: + return ( + + ); case PROJECT_ROUTE.hosting: return ( , ): ProjectNativeViewPresence => ({ assets: present.includes("assets"), + releases: present.includes("releases"), hosting: present.includes("hosting"), e2e: present.includes("e2e"), analytics: present.includes("analytics"), @@ -271,31 +272,35 @@ describe("project sidebar views", () => { projectSidebarViewUnavailable("assets", none, { assets: true, siteAccess: false, + releases: false, }), ).toBe(false); expect( projectSidebarViewUnavailable("hosting", none, { assets: false, siteAccess: true, + releases: false, }), ).toBe(false); expect( projectSidebarViewUnavailable("assets", none, { assets: false, siteAccess: false, + releases: false, }), ).toBe(true); expect( projectSidebarViewUnavailable("git", none, { assets: false, siteAccess: false, + releases: false, }), ).toBe(false); }); test("rejects source-backed views immediately when source is absent", () => { const none = projectSidebarViewPresence(false, nativePresence([])); - const pending = { assets: true, siteAccess: true }; + const pending = { assets: true, siteAccess: true, releases: false }; for (const viewId of ["overview", "reports", "board", "site-editor"]) { expect(projectSidebarViewUnavailable(viewId, none, pending)).toBe(true); @@ -308,7 +313,7 @@ describe("project sidebar views", () => { test("rejects unavailable Site Editor and retired surface defaults", () => { const absent = projectSidebarViewPresence(false, nativePresence([])); const present = projectSidebarViewPresence(true, nativePresence([])); - const settled = { assets: false, siteAccess: false }; + const settled = { assets: false, siteAccess: false, releases: false }; expect( projectDefaultViewUnavailable("preview", absent, settled, [], true), diff --git a/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts b/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts index 0cddcc0ca5..b92bf8656b 100644 --- a/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts +++ b/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts @@ -7,6 +7,7 @@ export const PROJECT_SIDEBAR_VIEW_IDS = [ "board", "site-editor", "assets", + "releases", "hosting", "e2e", "analytics", @@ -33,6 +34,7 @@ export type ProjectSidebarViewId = (typeof PROJECT_SIDEBAR_VIEW_IDS)[number]; /** Native panels whose availability is discovered from project resources. */ export const PROJECT_NATIVE_VIEW_IDS = [ "assets", + "releases", "hosting", "e2e", "analytics", @@ -48,6 +50,7 @@ export type ProjectSidebarViewPresence = Record; export interface ProjectNativeViewPending { assets: boolean; siteAccess: boolean; + releases: boolean; } export interface ProjectSidebarViewsMetadata { @@ -132,8 +135,8 @@ export function isProjectNativeViewId( return PROJECT_NATIVE_VIEW_IDS.some((viewId) => viewId === value); } -/** Combine permanent project capabilities with the five resource-backed - * native panels. */ +/** Combine permanent project capabilities with the resource-backed native + * panels. */ export function projectSidebarViewPresence( hasClonableSource: boolean, native: ProjectNativeViewPresence, @@ -144,6 +147,8 @@ export function projectSidebarViewPresence( board: hasClonableSource, "site-editor": hasClonableSource, assets: native.assets, + // Hosted Deco CMS releases: a v8 site's repo history. + releases: hasClonableSource && native.releases, hosting: native.hosting, e2e: native.e2e, analytics: native.analytics, @@ -212,7 +217,11 @@ export function projectSidebarViewUnavailable( ): boolean { if (!isProjectSidebarViewId(viewId) || presence[viewId]) return false; if (!isProjectNativeViewId(viewId)) return true; - return !(viewId === "assets" ? pending.assets : pending.siteAccess); + return !(viewId === "assets" + ? pending.assets + : viewId === "releases" + ? pending.releases + : pending.siteAccess); } /** Validate a persisted built-in landing view, including retired Preview and diff --git a/apps/web/src/layouts/main-panel-tabs/releases-api.ts b/apps/web/src/layouts/main-panel-tabs/releases-api.ts new file mode 100644 index 0000000000..cdc043191d --- /dev/null +++ b/apps/web/src/layouts/main-panel-tabs/releases-api.ts @@ -0,0 +1,106 @@ +/** + * The Releases screen's reads and writes (`/api/:org/hosted/:vmcp/*`): the + * timeline of main's commits with the ones that have a companion release, + * what latest.json names (Current), and "Make current". + */ + +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { KEYS } from "@/lib/query-keys"; + +export interface ReleasePointer { + revision: string; + schemaHash: string; + publishedAt: string; +} + +export interface ReleaseCommit { + sha: string; + date: string; + message: string; + author: string | null; + /** Has a companion release on the CDN: it can be made current. */ + hasRelease: boolean; +} + +export interface ReleasesPage { + /** What latest.json names; null when there is none. */ + current: ReleasePointer | null; + commits: ReleaseCommit[]; + nextCursor: string | null; +} + +/** A refused write, with the API's error code (`schema-mismatch`, + * `latest-update-failed`). */ +export class HostedRequestError extends Error { + constructor( + message: string, + readonly code: string | null, + ) { + super(message); + this.name = "HostedRequestError"; + } +} + +function hostedUrl(orgSlug: string, virtualMcpId: string, path: string) { + return `/api/${orgSlug}/hosted/${encodeURIComponent(virtualMcpId)}/${path}`; +} + +async function readJson(res: Response): Promise { + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + throw new HostedRequestError( + json.error ?? `HTTP ${res.status}`, + json.error ?? null, + ); + } + return json as T; +} + +export function useReleases(orgSlug: string, virtualMcpId: string) { + return useInfiniteQuery({ + queryKey: KEYS.hostedReleases(orgSlug, virtualMcpId), + queryFn: async ({ pageParam }) => { + const query = pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ""; + return readJson( + await fetch(hostedUrl(orgSlug, virtualMcpId, `releases${query}`), { + cache: "no-store", + }), + ); + }, + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + }); +} + +function useHostedWrite( + orgSlug: string, + virtualMcpId: string, + request: (vars: TVars) => Promise, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (vars: TVars) => readJson(await request(vars)), + onSettled: () => + queryClient.invalidateQueries({ + queryKey: KEYS.hostedReleases(orgSlug, virtualMcpId), + }), + }); +} + +/** "Make current": point latest.json at a commit's companion release. */ +export function useMakeCurrent(orgSlug: string, virtualMcpId: string) { + return useHostedWrite( + orgSlug, + virtualMcpId, + (vars: { sha: string; confirm: boolean }) => + fetch(hostedUrl(orgSlug, virtualMcpId, "releases/current"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(vars), + }), + ); +} diff --git a/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx new file mode 100644 index 0000000000..8b4e39828f --- /dev/null +++ b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx @@ -0,0 +1,243 @@ +/** + * Releases tab — a hosted v8 site's timeline: main's commits, newest first. + * A commit with a companion release on the CDN can be made current; the one + * latest.json names is Current (read, never inferred). A commit without a + * companion (a developer push, or a Publish whose release write failed) is + * just a merge: listed, with no badge and no action. + */ + +import { useState } from "react"; +import { ClockRewind, DotsVertical } from "@untitledui/icons"; +import { toast } from "sonner"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { useT } from "@/i18n/use-t.ts"; +import { formatTimeAgo } from "@/lib/format-time.ts"; +import { useProjectContext } from "@/sdk"; +import { + HostedRequestError, + type ReleaseCommit, + useMakeCurrent, + useReleases, +} from "./releases-api"; + +const short = (sha: string) => sha.slice(0, 7); + +/** What the confirmation dialog is asking about. */ +type PendingConfirm = { + commit: ReleaseCommit; + /** The target's schema differs from main's: the second, explicit ask. */ + schemaMismatch: boolean; +}; + +export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { + const t = useT(); + const { org } = useProjectContext(); + const releases = useReleases(org.slug, virtualMcpId); + const makeCurrent = useMakeCurrent(org.slug, virtualMcpId); + const [confirm, setConfirm] = useState(null); + + if (releases.isPending) { + return ( +
+ +
+ ); + } + if (releases.isError) { + return ( +
+

{t("releases.loadFailed")}

+

+ {releases.error.message} +

+
+ ); + } + + const commits = releases.data.pages.flatMap((page) => page.commits); + // What latest.json names, as the first page read it. + const { current } = releases.data.pages[0]!; + + const runMakeCurrent = async (commit: ReleaseCommit, confirmed: boolean) => { + try { + await makeCurrent.mutateAsync({ sha: commit.sha, confirm: confirmed }); + toast.success(t("releases.madeCurrent", { sha: short(commit.sha) })); + } catch (error) { + if ( + error instanceof HostedRequestError && + error.code === "schema-mismatch" + ) { + setConfirm({ commit, schemaMismatch: true }); + return; + } + // Failed: the badge stays where latest.json says (the list refetches). + if ( + error instanceof HostedRequestError && + error.code === "latest-update-failed" + ) { + toast.error(t("releases.makeCurrentFailed")); + return; + } + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + + const onConfirm = () => { + const pending = confirm; + setConfirm(null); + if (pending) void runMakeCurrent(pending.commit, pending.schemaMismatch); + }; + + return ( +
+
+

{t("releases.title")}

+

+ {t("releases.subtitle")} +

+

+ {current ? ( + <> + + {t("releases.currentOnCdn")}{" "} + + {short(current.revision)} + + {" · "} + {t("releases.madeCurrentAgo", { + when: formatTimeAgo(new Date(current.publishedAt)), + })} + + + ) : ( + + {t("releases.nothingOnCdn")} + + )} +

+
+ +
+
    + {commits.map((commit) => { + const isCurrent = commit.sha === current?.revision; + return ( +
  • + + {short(commit.sha)} + +
    +

    {commit.message}

    +

    + {[commit.author, formatTimeAgo(new Date(commit.date))] + .filter(Boolean) + .join(" · ")} +

    +
    + {isCurrent ? ( + + {t("releases.current")} + + ) : null} + {commit.hasRelease ? ( + + + + + + + setConfirm({ commit, schemaMismatch: false }) + } + > + + {t("releases.makeCurrent")} + + + + ) : null} +
  • + ); + })} +
+ {releases.hasNextPage ? ( +
+ +
+ ) : null} +
+ + { + if (!open) setConfirm(null); + }} + > + + + + {t("releases.makeCurrentTitle", { + sha: confirm ? short(confirm.commit.sha) : "", + })} + + + {confirm?.schemaMismatch + ? t("releases.schemaMismatchBody") + : t("releases.makeCurrentBody")} + + + + {t("releases.cancel")} + + {t("releases.makeCurrent")} + + + + +
+ ); +} diff --git a/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts b/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts index 722cd93920..3c198ebe2c 100644 --- a/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts +++ b/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts @@ -1,5 +1,6 @@ import type { ComponentType, SVGProps } from "react"; import { + ClockRewind, BarChartSquare02, CheckDone01, Code02, @@ -32,6 +33,7 @@ export type SystemTabId = | "code" | "content" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -46,6 +48,7 @@ export const SYSTEM_TAB_ICONS: Record = { code: Code02, content: File02, assets: Package, + releases: ClockRewind, hosting: Server01, e2e: CheckDone01, analytics: BarChartSquare02, diff --git a/apps/web/src/layouts/main-panel-tabs/tab-route.ts b/apps/web/src/layouts/main-panel-tabs/tab-route.ts index 85a28c98d0..bdd2364fa9 100644 --- a/apps/web/src/layouts/main-panel-tabs/tab-route.ts +++ b/apps/web/src/layouts/main-panel-tabs/tab-route.ts @@ -40,6 +40,7 @@ export type TabRouteLocation = section: | "settings" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -96,6 +97,7 @@ export type TabRouteTarget = to: | typeof PROJECT_ROUTE.settings | typeof PROJECT_ROUTE.assets + | typeof PROJECT_ROUTE.releases | typeof PROJECT_ROUTE.hosting | typeof PROJECT_ROUTE.e2e | typeof PROJECT_ROUTE.analytics @@ -232,6 +234,7 @@ const AGENT_SECTION_BY_TAB: Readonly< > = { settings: "settings", assets: "assets", + releases: "releases", hosting: "hosting", e2e: "e2e", analytics: "analytics", @@ -632,6 +635,7 @@ export function navigateToTabRouteTarget( return; case PROJECT_ROUTE.settings: case PROJECT_ROUTE.assets: + case PROJECT_ROUTE.releases: case PROJECT_ROUTE.hosting: case PROJECT_ROUTE.e2e: case PROJECT_ROUTE.analytics: diff --git a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index 538cf0ec7d..9f5f7ecdfb 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -224,6 +224,7 @@ export function useMainPanelTabs(ctx: { const nativeViewPending = { assets: nativeViews.assetsPending, siteAccess: nativeViews.siteAccessPending, + releases: nativeViews.releasesPending, }; const { activeTab: rawActiveTab, mainOpen } = resolveActiveTabAndOpen({ diff --git a/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts b/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts index 3797fc1288..28cb7502ab 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { matchSiteSlugConfig } from "@/components/file-picker/match-site-slug-config"; import { useFileConfigsQuery } from "@/hooks/use-file-configs"; -import { useControlPlaneViews } from "@/hooks/use-organization-settings"; +import { + useControlPlaneViews, + useOrgFlagState, +} from "@/hooks/use-organization-settings"; import { usePublicConfig } from "@/hooks/use-public-config"; import { KEYS } from "@/lib/query-keys"; import { useProjectContext } from "@/sdk"; @@ -16,10 +19,11 @@ export interface ProjectNativeViewPresenceResult { presence: ProjectNativeViewPresence; assetsPending: boolean; siteAccessPending: boolean; + releasesPending: boolean; } /** - * Resolve the five native per-project views once for every surface that needs + * Resolve the native per-project views once for every surface that needs * them. Product rollout, deployment wiring, tenant ownership, and per-site * resources are presence; the Layout preference is deliberately layered on by * the sidebar only. @@ -31,6 +35,8 @@ export function useProjectNativeViewPresence( const config = usePublicConfig(); const controlPlaneViews = useControlPlaneViews(); const fileConfigs = useFileConfigsQuery(); + // The hosted Deco CMS (Blocks v8) is behind the content-protocol flag. + const hostedCms = useOrgFlagState("site_editor_content_protocol"); const siteSlug = resolveAgentSiteSlug(project); const hostingEnabled = config.hostingEnabled === true; const monitorEnabled = @@ -56,6 +62,7 @@ export function useProjectNativeViewPresence( return { presence: { assets: !!matchSiteSlugConfig(fileConfigs.data?.configs ?? [], siteSlug), + releases: hostedCms === true && !!project?.metadata?.siteSlug, hosting: hostingEnabled && ownsSite && controlPlaneViews.hosting, e2e: hostingEnabled && ownsSite && controlPlaneViews.e2e, analytics: hostingEnabled && ownsSite && controlPlaneViews.analytics, @@ -63,6 +70,7 @@ export function useProjectNativeViewPresence( experiments: controlPlaneViews.experiments, }, assetsPending: !!siteSlug && fileConfigs.isPending, + releasesPending: hostedCms === undefined, // A disabled TanStack query is still pending; it is only unresolved when a // probe can actually run. siteAccessPending: diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 61749c6289..a6d1c92d74 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -815,6 +815,21 @@ const agentAssetsRoute = createRoute({ ), }); +const agentReleasesRoute = createRoute({ + pendingComponent: ChatLayoutPending, + errorComponent: ChatLayoutError, + getParentRoute: () => agentWorkspaceRoute, + path: "/releases", + staticData: { + pageTitle: "common.mainPanelTabs.releases", + defaultMain: "releases", + mainView: "releases", + }, + component: lazyRouteComponent( + () => import("./routes/workspace/agent-releases.tsx"), + ), +}); + const agentHostingRoute = createRoute({ pendingComponent: ChatLayoutPending, errorComponent: ChatLayoutError, @@ -1598,6 +1613,7 @@ const agentWorkspaceWithChildren = agentWorkspaceRoute.addChildren([ agentSettingsRoute, agentLibraryRoute, agentAssetsRoute, + agentReleasesRoute, agentHostingRoute, agentE2eRoute, agentAnalyticsRoute, diff --git a/apps/web/src/routes/workspace/agent-releases.tsx b/apps/web/src/routes/workspace/agent-releases.tsx new file mode 100644 index 0000000000..a612b789c4 --- /dev/null +++ b/apps/web/src/routes/workspace/agent-releases.tsx @@ -0,0 +1,21 @@ +import { ChatLayout } from "@/components/chat-layout"; +import { AgentViewGuard } from "./agent-view-guard"; +import { ReleasesTab } from "@/layouts/main-panel-tabs/releases-tab"; +import { useRouteVirtualMcpId } from "@/layouts/thread-route"; + +function AgentReleasesContent() { + const virtualMcpId = useRouteVirtualMcpId(); + return ( + + + + ); +} + +export default function AgentReleasesRoute() { + return ( + + + + ); +} diff --git a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts index 215bb65927..592a79812b 100644 --- a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts +++ b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts @@ -414,6 +414,7 @@ export function useProjectViews({ board: t("sidebar.navDestinations.tasks"), "site-editor": t("virtualMcp.layoutTabContent.siteEditor"), assets: t("common.mainPanelTabs.assets"), + releases: t("common.mainPanelTabs.releases"), hosting: t("common.mainPanelTabs.hosting"), e2e: t("common.mainPanelTabs.e2e"), analytics: t("common.mainPanelTabs.analytics"), diff --git a/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx b/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx index f19ea18700..0fda8d90f6 100644 --- a/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx +++ b/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx @@ -4,6 +4,7 @@ */ import { + ClockRewind, ArrowUpRight, BarChartSquare02, CheckDone01, @@ -39,6 +40,7 @@ export function SidebarViewIcon({ viewId }: { viewId: ProjectSidebarViewId }) { if (viewId === "board") return ; if (viewId === "site-editor") return ; if (viewId === "assets") return ; + if (viewId === "releases") return ; if (viewId === "hosting") return ; if (viewId === "e2e") return ; if (viewId === "analytics") return ; diff --git a/packages/shared/src/sdk/types/virtual-mcp.ts b/packages/shared/src/sdk/types/virtual-mcp.ts index 13ec929e91..fb4209fa8f 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -145,6 +145,7 @@ export const VirtualMcpSidebarViewSchema = z.enum([ "board", "site-editor", "assets", + "releases", "hosting", "e2e", "analytics", diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 089219a7fa..f5c4add63e 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -2516,6 +2516,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2575,6 +2576,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2783,6 +2785,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2842,6 +2845,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2974,6 +2978,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3033,6 +3038,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3202,6 +3208,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3261,6 +3268,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3421,6 +3429,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3480,6 +3489,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3640,6 +3650,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3699,6 +3710,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3839,6 +3851,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3898,6 +3911,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -4056,6 +4070,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -4115,6 +4130,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -5415,6 +5431,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -5474,6 +5491,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" From 50e00c67d75ab9578422ab2f5205d291a3be7905 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 11:57:22 -0300 Subject: [PATCH 2/4] fix(web): Versions screen in business language, one toast per action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Releases screen speaks to business users: "Published" is the version the site shows (badge, header "Published 2h ago", "Nothing published yet"), and "Publish this version" replaces "Make current". No sha, CDN or git words on screen; the sha moves to a tooltip and a row shows its version note's title line only. The English tab and app card are renamed "Versions", matching pt-br. Publishing a version shows one toast, shared with the editor's Publish (sonner id site-editor-publish): "Published · This version is live.", or "Couldn't publish this version." with Try again and the error's own text behind Details. A failed load offers Try again, with Details. The jargon test now covers releases.* and the Versions nav labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/serve-save-error.test.ts | 42 ++++++- .../sections-editor/site-editor-toast.tsx | 66 ++++++++++- apps/web/src/i18n/en/common.ts | 2 +- apps/web/src/i18n/en/projects.ts | 4 +- apps/web/src/i18n/en/releases.ts | 35 +++--- apps/web/src/i18n/en/site-editor.ts | 2 + apps/web/src/i18n/pt-br/projects.ts | 2 +- apps/web/src/i18n/pt-br/releases.ts | 27 +++-- apps/web/src/i18n/pt-br/site-editor.ts | 2 + apps/web/src/i18n/site-editor-copy.test.ts | 8 +- .../layouts/main-panel-tabs/releases-tab.tsx | 107 ++++++++++-------- 11 files changed, 213 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts index 3469465d26..aca7c5c22f 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.test.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -9,7 +9,13 @@ import { saveErrorDetail, saveErrorMessage, } from "./serve-save-error"; -import { SITE_EDITOR_SAVE_TOAST, toastSaveError } from "./site-editor-toast"; +import { + SITE_EDITOR_PUBLISH_TOAST, + SITE_EDITOR_SAVE_TOAST, + toastPublishFailed, + toastPublished, + toastSaveError, +} from "./site-editor-toast"; const t: TFunction = (key, vars) => interpolate(en[key], vars); @@ -84,3 +90,37 @@ describe("one toast per save failure", () => { expect(shown).toHaveLength(1); }); }); + +describe("one toast per publish", () => { + afterEach(() => { + toast.dismiss(); + }); + + test("a failure, its retry and the success share one toast", () => { + let retried = 0; + toastPublishFailed(t, { + headline: "Couldn't publish this version.", + detail: "latest-update-failed", + retry: () => { + retried++; + }, + }); + const failed = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect(failed).toHaveLength(1); + const action = (failed[0] as { action?: { label: string } }).action; + expect(action?.label).toBe("Try again"); + // The headline never carries the developer detail. + expect((failed[0] as { title?: unknown }).title).toBe( + "Couldn't publish this version.", + ); + toastPublished(t, "This version is live."); + const shown = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect(shown).toHaveLength(1); + expect((shown[0] as { title?: unknown }).title).toBe("Published"); + expect(retried).toBe(0); + }); +}); 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..4b65067480 100644 --- a/apps/web/src/components/sections-editor/site-editor-toast.tsx +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -13,9 +13,18 @@ import { saveErrorDetail, saveErrorMessage } from "./serve-save-error"; /** Every save failure in the site editor shares this toast. */ export const SITE_EDITOR_SAVE_TOAST = "site-editor-save"; +/** Publishing (from the editor or the Versions screen), its outcome and a + * retry of it share this toast. */ +export const SITE_EDITOR_PUBLISH_TOAST = "site-editor-publish"; /** A collapsed "Details" line holding the developer detail of a failure. */ -function ErrorDetails({ label, detail }: { label: string; detail: string }) { +export function ErrorDetails({ + label, + detail, +}: { + label: string; + detail: string; +}) { return (
@@ -46,3 +55,58 @@ export function toastSaveError( description: errorDetailsDescription(t, saveErrorDetail(error, source)), }); } + +/** Published: what the site shows now. `description` says what went live. */ +export function toastPublished(t: TFunction, description: string) { + toast.success(t("siteEditor.publish.published"), { + id: SITE_EDITOR_PUBLISH_TOAST, + description, + }); +} + +/** + * Publishing didn't finish: `headline` says so in plain words, `detail` (the + * error's own text) sits behind Details, and `retry` offers "Try again". + * `tone: "warning"` when nothing is lost (the changes are saved). + */ +export function toastPublishFailed( + t: TFunction, + { + headline, + body, + detail, + retry, + tone = "error", + }: { + headline: string; + body?: string; + detail?: string | null; + retry?: () => void; + tone?: "error" | "warning"; + }, +) { + const details = errorDetailsDescription(t, detail ?? null); + toast[tone](headline, { + id: SITE_EDITOR_PUBLISH_TOAST, + description: + body && details ? ( + <> +

{body}

+ {details} + + ) : ( + (body ?? details) + ), + action: retry + ? { label: t("siteEditor.tryAgain"), onClick: retry } + : undefined, + // A failure waits for the user: it carries the next step. + duration: retry ? Number.POSITIVE_INFINITY : undefined, + }); +} + +/** 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/i18n/en/common.ts b/apps/web/src/i18n/en/common.ts index 2e128554af..c87b349e8e 100644 --- a/apps/web/src/i18n/en/common.ts +++ b/apps/web/src/i18n/en/common.ts @@ -269,7 +269,7 @@ export const common = { "common.mainPanelTabs.code": "Code", "common.mainPanelTabs.content": "Content", "common.mainPanelTabs.assets": "Assets", - "common.mainPanelTabs.releases": "Releases", + "common.mainPanelTabs.releases": "Versions", "common.mainPanelTabs.hosting": "Hosting", "common.mainPanelTabs.e2e": "E2E", "common.mainPanelTabs.analytics": "Deco Analytics", diff --git a/apps/web/src/i18n/en/projects.ts b/apps/web/src/i18n/en/projects.ts index 44ac9b7e53..28b05fdc4c 100644 --- a/apps/web/src/i18n/en/projects.ts +++ b/apps/web/src/i18n/en/projects.ts @@ -76,8 +76,8 @@ export const projects = { "projects.apps.siteEditorCaption": "Pages, sections and content", "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Images and files the site uses", - "projects.apps.releases": "Releases", - "projects.apps.releasesCaption": "What the site serves, and rollbacks", + "projects.apps.releases": "Versions", + "projects.apps.releasesCaption": "What's live, and earlier versions", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domains and environments", "projects.apps.e2e": "End-to-end tests", diff --git a/apps/web/src/i18n/en/releases.ts b/apps/web/src/i18n/en/releases.ts index 91daefcf0a..2dad88f854 100644 --- a/apps/web/src/i18n/en/releases.ts +++ b/apps/web/src/i18n/en/releases.ts @@ -1,21 +1,26 @@ +/** + * The Versions screen of a hosted site (the `releases` route). Business-user + * copy: "Published" is the version the site shows; no CDN, git, commit, sha + * or "current" (`site-editor-copy.test.ts` enforces it). + */ export const releases = { - "releases.title": "Releases", + "releases.title": "Versions", "releases.subtitle": - "Every change merged to main, newest first. A Publish also creates a release on the CDN; Make current switches the CDN to any release.", - "releases.loadFailed": "Couldn't load releases", - "releases.currentOnCdn": "Current on the CDN:", - "releases.madeCurrentAgo": "made current {when}", - "releases.nothingOnCdn": "Nothing on the CDN yet", - "releases.current": "Current", - "releases.actions": "Release actions", - "releases.makeCurrent": "Make current", - "releases.makeCurrentFailed": "Making it current failed. Try again.", - "releases.makeCurrentTitle": "Make {sha} current?", - "releases.makeCurrentBody": - "The CDN serves this release from now on. Git doesn't change, and the next Publish makes its own release current.", + "Every version of your site, newest first. You can publish an earlier one to bring it back.", + "releases.loadFailed": "Couldn't load your versions.", + "releases.publishedAgo": "Published {when}", + "releases.nothingPublished": "Nothing published yet", + "releases.published": "Published", + "releases.actions": "Version actions", + "releases.publishVersion": "Publish this version", + "releases.publishVersionTitle": "Publish this version?", + "releases.publishVersionBody": + "Your site will show this version from now on. Your next Publish replaces it with your latest changes.", "releases.schemaMismatchBody": - "This release was made with a different schema than main's. Sites built from main keep their current content until the schemas match again.", - "releases.madeCurrent": "{sha} is now current", + "This version was made for an earlier design of your site. Your site keeps showing what it shows now until its design matches this version again. Publish anyway?", + "releases.publishAnyway": "Publish anyway", + "releases.versionLive": "This version is live.", + "releases.publishVersionFailed": "Couldn't publish this version.", "releases.loadMore": "Load more", "releases.cancel": "Cancel", }; diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index 1ce4919223..921ec2b6b5 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -6,6 +6,8 @@ */ export const siteEditor = { "siteEditor.details": "Details", + "siteEditor.tryAgain": "Try again", + "siteEditor.publish.published": "Published", "siteEditor.save.failed": "Couldn't save your change. Try again.", "siteEditor.save.conflict": "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", diff --git a/apps/web/src/i18n/pt-br/projects.ts b/apps/web/src/i18n/pt-br/projects.ts index 228222e0d3..cf0747f3e5 100644 --- a/apps/web/src/i18n/pt-br/projects.ts +++ b/apps/web/src/i18n/pt-br/projects.ts @@ -71,7 +71,7 @@ export const projects = { "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Imagens e arquivos que o site usa", "projects.apps.releases": "Versões", - "projects.apps.releasesCaption": "O que o site serve, e reversões", + "projects.apps.releasesCaption": "O que está no ar e versões anteriores", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domínios e ambientes", "projects.apps.e2e": "Testes ponta a ponta", diff --git a/apps/web/src/i18n/pt-br/releases.ts b/apps/web/src/i18n/pt-br/releases.ts index 729faac8fc..e836c8f0f4 100644 --- a/apps/web/src/i18n/pt-br/releases.ts +++ b/apps/web/src/i18n/pt-br/releases.ts @@ -3,22 +3,21 @@ import type { releases as enReleases } from "../en/releases.ts"; export const releases = { "releases.title": "Versões", "releases.subtitle": - "Todas as alterações mescladas na main, da mais recente para a mais antiga. Publicar também cria uma versão na CDN; Tornar atual faz a CDN servir qualquer versão.", - "releases.loadFailed": "Não foi possível carregar as versões", - "releases.currentOnCdn": "Atual na CDN:", - "releases.madeCurrentAgo": "tornada atual {when}", - "releases.nothingOnCdn": "Nada na CDN ainda", - "releases.current": "Atual", + "Todas as versões do seu site, da mais recente para a mais antiga. Você pode publicar uma anterior para trazê-la de volta.", + "releases.loadFailed": "Não foi possível carregar suas versões.", + "releases.publishedAgo": "Publicada {when}", + "releases.nothingPublished": "Nada publicado ainda", + "releases.published": "Publicada", "releases.actions": "Ações da versão", - "releases.makeCurrent": "Tornar atual", - "releases.makeCurrentFailed": - "Não foi possível torná-la atual. Tente de novo.", - "releases.makeCurrentTitle": "Tornar {sha} a versão atual?", - "releases.makeCurrentBody": - "A CDN passa a servir esta versão. O git não muda, e a próxima publicação torna a sua própria versão atual.", + "releases.publishVersion": "Publicar esta versão", + "releases.publishVersionTitle": "Publicar esta versão?", + "releases.publishVersionBody": + "Seu site passa a mostrar esta versão. A próxima publicação a substitui pelas suas alterações mais recentes.", "releases.schemaMismatchBody": - "Esta versão foi criada com um schema diferente do da main. Sites gerados a partir da main mantêm o conteúdo atual até os schemas voltarem a coincidir.", - "releases.madeCurrent": "{sha} agora é a versão atual", + "Esta versão foi feita para um design anterior do seu site. O site continua mostrando o que mostra agora até o design voltar a corresponder a esta versão. Publicar mesmo assim?", + "releases.publishAnyway": "Publicar mesmo assim", + "releases.versionLive": "Esta versão está no ar.", + "releases.publishVersionFailed": "Não foi possível publicar esta versão.", "releases.loadMore": "Carregar mais", "releases.cancel": "Cancelar", } satisfies Record; diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index 8de443666f..e2813e0006 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -2,6 +2,8 @@ import type { siteEditor as siteEditorEn } from "../en/site-editor.ts"; export const siteEditor = { "siteEditor.details": "Detalhes", + "siteEditor.tryAgain": "Tentar de novo", + "siteEditor.publish.published": "Publicado", "siteEditor.save.failed": "Não foi possível salvar sua alteração. Tente de novo.", "siteEditor.save.conflict": diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts index 18c66225ad..6af8739aea 100644 --- a/apps/web/src/i18n/site-editor-copy.test.ts +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -8,7 +8,12 @@ import { en } from "./en/index.ts"; import { ptBR } from "./pt-br/index.ts"; /** Namespaces whose every string a business user reads in the site editor. */ -const SITE_EDITOR_PREFIXES = ["siteEditor."]; +const SITE_EDITOR_PREFIXES = [ + "siteEditor.", + "releases.", + "common.mainPanelTabs.releases", + "projects.apps.releases", +]; const BANNED: RegExp[] = [ /\bCDN\b/i, @@ -21,6 +26,7 @@ const BANNED: RegExp[] = [ /latest\.json/i, /\brevision/i, /\bsha\b/i, + /\bcurrent\b/i, /make current/i, /tornar atual/i, ]; diff --git a/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx index 8b4e39828f..109ed00b36 100644 --- a/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx +++ b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx @@ -1,14 +1,17 @@ /** - * Releases tab — a hosted v8 site's timeline: main's commits, newest first. - * A commit with a companion release on the CDN can be made current; the one - * latest.json names is Current (read, never inferred). A commit without a - * companion (a developer push, or a Publish whose release write failed) is - * just a merge: listed, with no badge and no action. + * Versions tab (the `releases` route) — a hosted v8 site's timeline: main's + * commits, newest first. A commit with a companion release on the CDN can be + * published ("Make current" on the API); the one latest.json names is + * Published (read, never inferred). A commit without a companion (a + * developer push, or a Publish whose release write failed) is listed with no + * badge and no action. + * + * Business-user copy: no sha, CDN or git words on screen; one toast per + * action (shared with Publish), with the error's own text behind Details. */ import { useState } from "react"; import { ClockRewind, DotsVertical } from "@untitledui/icons"; -import { toast } from "sonner"; import { Button } from "@decocms/ui/components/button.tsx"; import { AlertDialog, @@ -31,6 +34,12 @@ import { cn } from "@decocms/ui/lib/utils.ts"; import { useT } from "@/i18n/use-t.ts"; import { formatTimeAgo } from "@/lib/format-time.ts"; import { useProjectContext } from "@/sdk"; +import { + ErrorDetails, + errorDetail, + toastPublishFailed, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; import { HostedRequestError, type ReleaseCommit, @@ -39,6 +48,8 @@ import { } from "./releases-api"; const short = (sha: string) => sha.slice(0, 7); +/** A commit message's title line: the version note a Publish wrote. */ +const titleLine = (message: string) => message.split("\n")[0]?.trim() ?? ""; /** What the confirmation dialog is asking about. */ type PendingConfirm = { @@ -65,9 +76,23 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { return (

{t("releases.loadFailed")}

-

- {releases.error.message} -

+ + {releases.error.message ? ( +
+ +
+ ) : null}
); } @@ -79,7 +104,7 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { const runMakeCurrent = async (commit: ReleaseCommit, confirmed: boolean) => { try { await makeCurrent.mutateAsync({ sha: commit.sha, confirm: confirmed }); - toast.success(t("releases.madeCurrent", { sha: short(commit.sha) })); + toastPublished(t, t("releases.versionLive")); } catch (error) { if ( error instanceof HostedRequestError && @@ -89,14 +114,11 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { return; } // Failed: the badge stays where latest.json says (the list refetches). - if ( - error instanceof HostedRequestError && - error.code === "latest-update-failed" - ) { - toast.error(t("releases.makeCurrentFailed")); - return; - } - toast.error(error instanceof Error ? error.message : String(error)); + toastPublishFailed(t, { + headline: t("releases.publishVersionFailed"), + detail: errorDetail(error), + retry: () => void runMakeCurrent(commit, confirmed), + }); } }; @@ -114,24 +136,13 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { {t("releases.subtitle")}

- {current ? ( - <> - - {t("releases.currentOnCdn")}{" "} - - {short(current.revision)} - - {" · "} - {t("releases.madeCurrentAgo", { + + {current + ? t("releases.publishedAgo", { when: formatTimeAgo(new Date(current.publishedAt)), - })} - - - ) : ( - - {t("releases.nothingOnCdn")} - - )} + }) + : t("releases.nothingPublished")} +

@@ -147,11 +158,11 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { isCurrent && "bg-muted/40", )} > - - {short(commit.sha)} - -
-

{commit.message}

+ {/* The sha is developer detail: a tooltip, never on screen. */} +
+

+ {titleLine(commit.message)} +

{[commit.author, formatTimeAgo(new Date(commit.date))] .filter(Boolean) @@ -160,10 +171,10 @@ export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) {

{isCurrent ? ( - {t("releases.current")} + {t("releases.published")} ) : null} - {commit.hasRelease ? ( + {commit.hasRelease && !isCurrent ? (