From 2a0426e2a0daf41916edc6511e755cea2c76253e Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:11:26 -0300 Subject: [PATCH 1/3] feat(web): hosted Publish and site tokens Adds hosted Publish (merge, release, make current) and the site tokens section in project settings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../thread/repository/cms-header-actions.tsx | 13 +- .../thread/repository/hosted-publish-api.ts | 50 ++++++ .../thread/repository/publish-dialog.tsx | 10 +- .../repository/use-cms-publish-actions.ts | 59 ++++++- apps/web/src/i18n/en/index.ts | 2 + apps/web/src/i18n/en/site-tokens.ts | 14 ++ apps/web/src/i18n/en/thread.ts | 8 + apps/web/src/i18n/pt-br/index.ts | 2 + apps/web/src/i18n/pt-br/site-tokens.ts | 16 ++ apps/web/src/i18n/pt-br/thread.ts | 8 + apps/web/src/views/virtual-mcp/index.tsx | 11 ++ .../settings/site-token-section.tsx | 160 ++++++++++++++++++ 12 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/thread/repository/hosted-publish-api.ts create mode 100644 apps/web/src/i18n/en/site-tokens.ts create mode 100644 apps/web/src/i18n/pt-br/site-tokens.ts create mode 100644 apps/web/src/views/virtual-mcp/settings/site-token-section.tsx diff --git a/apps/web/src/components/thread/repository/cms-header-actions.tsx b/apps/web/src/components/thread/repository/cms-header-actions.tsx index e2543243af..429cd7d1ae 100644 --- a/apps/web/src/components/thread/repository/cms-header-actions.tsx +++ b/apps/web/src/components/thread/repository/cms-header-actions.tsx @@ -38,6 +38,7 @@ import { import { KEYS } from "@/lib/query-keys"; import { useProjectContext, useVirtualMCP } from "@/sdk"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; +import { useContentBackend } from "../../sections-editor/use-content-backend.ts"; import { useDecofileWriting } from "../../sections-editor/use-decofile-writing.ts"; import { useDraftPointer, @@ -311,7 +312,11 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { */ const publishing = publishCompletion.isPending; - const button = selectCmsHeaderButton({ + // A hosted v8 draft is not a branch: there is no pull request to submit + // for review, so its header offers Publish only. + const backend = useContentBackend(virtualMcpId, branch); + const hosted = backend.kind === "protocol" && backend.source === "github"; + const selected = selectCmsHeaderButton({ branch: branchMeta, pr, checks: checksQuery.data ?? [], @@ -330,6 +335,12 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { publishableChangeCount, t, }); + const button = hosted + ? { + ...selected, + menu: selected.menu.filter((item) => item.key !== "request-approval"), + } + : selected; /** Keyed by head so a failed sync isn't retried until the branch moves. */ const autoGetLatestKey = diff --git a/apps/web/src/components/thread/repository/hosted-publish-api.ts b/apps/web/src/components/thread/repository/hosted-publish-api.ts new file mode 100644 index 0000000000..8218fdab1e --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-api.ts @@ -0,0 +1,50 @@ +/** + * Publish for a hosted v8 site (Blocks v8 on GitHub): commits the project's + * CDN draft straight to main, writes that commit's companion release and + * makes it current on the CDN. `release` says how far the CDN steps got: + * `current`, `created` (making it current failed: Make current on Releases) + * or `none` (no release: the next Publish includes these changes). + */ + +export type HostedPublishResult = + | { result: "merged"; sha: string; release: "current" | "created" | "none" } + | { result: "up-to-date" }; + +/** A refused publish, with the API's error code (`main-moved`). */ +export class HostedPublishError extends Error { + constructor( + message: string, + readonly code: string | null, + ) { + super(message); + this.name = "HostedPublishError"; + } +} + +async function post(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as { + error?: string; + } & Partial; + if (!res.ok) { + throw new HostedPublishError( + json.error ?? `HTTP ${res.status}`, + json.error ?? null, + ); + } + return json as HostedPublishResult; +} + +export function publishHostedDraft( + params: { orgSlug: string; virtualMcpId: string; branch: string }, + note: string, +): Promise { + return post( + `/api/${params.orgSlug}/decofile/${encodeURIComponent(params.virtualMcpId)}/${encodeURIComponent(params.branch)}/publish`, + { note }, + ); +} diff --git a/apps/web/src/components/thread/repository/publish-dialog.tsx b/apps/web/src/components/thread/repository/publish-dialog.tsx index 0667b6e842..923db099bf 100644 --- a/apps/web/src/components/thread/repository/publish-dialog.tsx +++ b/apps/web/src/components/thread/repository/publish-dialog.tsx @@ -73,6 +73,7 @@ import { import { useCmsPublishState } from "./use-cms-publish-state.ts"; import { useResolvedPublishGate } from "@/components/sandbox/hooks/use-publish-gate.ts"; import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend.ts"; export type { CmsPublishMode }; @@ -460,7 +461,10 @@ function CmsPublishContent({ ] .filter((id) => changes.some((change) => changeId(change) === id)) .slice(0, MAX_LIVE_PANES); - const isReview = mode === "review"; + const backend = useContentBackend(virtualMcpId, branch); + const hosted = backend.kind === "protocol" && backend.source === "github"; + // A hosted v8 draft has no pull request: review mode doesn't apply to it. + const isReview = mode === "review" && !hosted; const surfaceState: PublishSurfaceState = cardsPending ? "loading" : bodiesPending @@ -508,7 +512,7 @@ function CmsPublishContent({ discardChange, discardAll, } = useCmsPublishActions({ - mode, + mode: isReview ? "review" : "publish", target, note, allPaths, @@ -518,6 +522,7 @@ function CmsPublishContent({ refresh, onPullRequestChanged, onPublished, + hosted, }); const canDiscard = (paths: readonly string[]) => @@ -785,6 +790,7 @@ function CmsPublishContent({
{!isReview && + !hosted && summary.count > 0 && !gate.allowed && !gate.pending ? ( diff --git a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts index 1defd99bfe..3d37c46ee7 100644 --- a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts +++ b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts @@ -6,9 +6,11 @@ */ import type { MutableRefObject } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; import { useT } from "@/i18n/use-t.ts"; +import { KEYS } from "@/lib/query-keys.ts"; import type { PublishChange } from "./publish-change-summary.ts"; import { notifySubmittedForReview, @@ -19,6 +21,11 @@ import { type PublishTarget, } from "./publish-flow.ts"; import { discardGitFiles } from "./sandbox-git-api.ts"; +import { + HostedPublishError, + type HostedPublishResult, + publishHostedDraft, +} from "./hosted-publish-api.ts"; /** `publish` merges to production; `review` stops at the pull request. */ export type CmsPublishMode = "publish" | "review"; @@ -38,6 +45,12 @@ interface CmsPublishActionsArgs { refresh: () => Promise; onPullRequestChanged?: () => void | Promise; onPublished?: () => void | Promise; + /** + * A hosted v8 site: publish commits the CDN draft to main, creates its + * release and makes it current (no pull request). It's done once merged; a + * release that isn't current is made current from the Releases screen. + */ + hosted?: boolean; } interface CmsPublishActions { @@ -64,8 +77,10 @@ export function useCmsPublishActions( refresh, onPullRequestChanged, onPublished, + hosted = false, } = args; const t = useT(); + const queryClient = useQueryClient(); const [isPublishing, setIsPublishing] = useState(false); const [isDiscarding, setIsDiscarding] = useState(false); const [publishError, setPublishError] = useState(); @@ -76,6 +91,46 @@ export function useCmsPublishActions( t("thread.publishDialog.changesFrom", { branch: target.headBranch }), ); + /** Merged is done: the popover closes, saying what the CDN serves. */ + const settleHosted = async (result: HostedPublishResult) => { + if (result.result === "up-to-date") { + toast.success(t("thread.publishPopover.upToDate")); + } else if (result.release === "current") { + toast.success(t("thread.publishPopover.mergedCurrent")); + } else if (result.release === "created") { + toast.warning(t("thread.publishPopover.mergedNotCurrent")); + } else { + toast.warning(t("thread.publishPopover.mergedNoRelease")); + } + // A mounted Releases screen shows the new commit and what is Current. + void queryClient.invalidateQueries({ + queryKey: KEYS.hostedReleases(target.orgSlug, target.virtualMcpId), + }); + onOpenChange(false); + await onPublished?.(); + }; + + const publishHosted = async () => { + publishLockRef.current = true; + setIsPublishing(true); + setPublishError(undefined); + try { + await settleHosted(await publishHostedDraft(target, noteParts().message)); + } catch (error) { + setPublishError( + error instanceof HostedPublishError && error.code === "main-moved" + ? t("thread.publishPopover.mainMoved") + : error instanceof Error + ? error.message + : t("thread.publishDialog.failedPublish"), + ); + await refresh(); + } finally { + publishLockRef.current = false; + setIsPublishing(false); + } + }; + const publish = async () => { publishLockRef.current = true; setIsPublishing(true); @@ -157,7 +212,9 @@ export function useCmsPublishActions( isPublishing, isDiscarding, publishError, - submit: mode === "review" ? submitForReview : publish, + // Hosted callers never pass review mode (no pull request to open). + submit: + mode === "review" ? submitForReview : hosted ? publishHosted : publish, discardChange: (change) => discardFiles( change.filepaths, diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index 11ccf2552e..e515ec03ad 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -31,6 +31,7 @@ import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; import { releases } from "./releases.ts"; +import { siteTokens } from "./site-tokens.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -67,6 +68,7 @@ export const en = { ...mainPanelTabs, ...experiments, ...releases, + ...siteTokens, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/en/site-tokens.ts b/apps/web/src/i18n/en/site-tokens.ts new file mode 100644 index 0000000000..4510edf349 --- /dev/null +++ b/apps/web/src/i18n/en/site-tokens.ts @@ -0,0 +1,14 @@ +export const siteTokens = { + "siteTokens.title": "Site tokens", + "siteTokens.description": + "Your site passes its site ID and a token to createCMS({ site, token }) to send telemetry. Keep the token on the server.", + "siteTokens.siteId": "Site ID", + "siteTokens.issue": "Issue token", + "siteTokens.issuedAgo": "Issued {when}", + "siteTokens.createdTitle": "Copy your site token", + "siteTokens.createdDescription": + "This is the only time it's shown. Store it as a server secret of your site.", + "siteTokens.copy": "Copy token", + "siteTokens.copied": "Token copied", + "siteTokens.done": "Done", +}; diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 5ef15ccc32..b55f708205 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -152,6 +152,14 @@ export const thread = { "thread.publishDialog.viewOnProvider": "View on provider", "thread.publishDialog.viewPr": "View PR", "thread.publishPopover.blocksGroup": "Blocks", + "thread.publishPopover.mergedCurrent": "Merged · Current on the CDN", + "thread.publishPopover.mergedNotCurrent": + "Merged · release created, making it current failed — use Make current on Releases", + "thread.publishPopover.mergedNoRelease": + "Merged · no release created (the next Publish includes these changes)", + "thread.publishPopover.upToDate": "Up to date, nothing to publish", + "thread.publishPopover.mainMoved": + "Main changed while publishing, so nothing was published. Publish again.", "thread.publishPopover.branchMoved": "This branch changed since these changes were shown. Close and reopen to review what will be published.", "thread.publishPopover.detailsUnavailable": diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 4a1f47999b..c9d919a7fc 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -31,6 +31,7 @@ import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; import { releases } from "./releases.ts"; +import { siteTokens } from "./site-tokens.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -65,6 +66,7 @@ export const ptBR = { ...mainPanelTabs, ...experiments, ...releases, + ...siteTokens, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/pt-br/site-tokens.ts b/apps/web/src/i18n/pt-br/site-tokens.ts new file mode 100644 index 0000000000..b484c20abf --- /dev/null +++ b/apps/web/src/i18n/pt-br/site-tokens.ts @@ -0,0 +1,16 @@ +import type { siteTokens as enSiteTokens } from "../en/site-tokens.ts"; + +export const siteTokens = { + "siteTokens.title": "Tokens do site", + "siteTokens.description": + "O seu site passa o ID do site e um token para createCMS({ site, token }) para enviar telemetria. Mantenha o token no servidor.", + "siteTokens.siteId": "ID do site", + "siteTokens.issue": "Emitir token", + "siteTokens.issuedAgo": "Emitido {when}", + "siteTokens.createdTitle": "Copie o token do site", + "siteTokens.createdDescription": + "Ele só é mostrado agora. Guarde-o como um segredo de servidor do seu site.", + "siteTokens.copy": "Copiar token", + "siteTokens.copied": "Token copiado", + "siteTokens.done": "Concluir", +} satisfies Record; diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index c69237c9e0..25c4263c86 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -159,6 +159,14 @@ export const thread = { "thread.publishDialog.viewOnProvider": "Ver no provedor", "thread.publishDialog.viewPr": "Ver PR", "thread.publishPopover.blocksGroup": "Blocos", + "thread.publishPopover.mergedCurrent": "Mesclado · Atual na CDN", + "thread.publishPopover.mergedNotCurrent": + "Mesclado · versão criada, mas não foi possível torná-la atual — use Tornar atual em Versões", + "thread.publishPopover.mergedNoRelease": + "Mesclado · nenhuma versão criada (a próxima publicação inclui estas alterações)", + "thread.publishPopover.upToDate": "Tudo atualizado, nada para publicar", + "thread.publishPopover.mainMoved": + "A main mudou durante a publicação, então nada foi publicado. Publique novamente.", "thread.publishPopover.branchMoved": "Esta branch mudou depois que estas alterações foram exibidas. Feche e abra novamente para revisar o que será publicado.", "thread.publishPopover.detailsUnavailable": diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index 201f6a9a55..bbd6cd6cd9 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -81,6 +81,8 @@ import { SettingsSection, } from "@/components/settings/settings-section"; import { useProjectSettingsSection } from "./settings/use-settings-section"; +import { SiteTokenSection } from "./settings/site-token-section"; +import { useOrgFlagState } from "@/hooks/use-organization-settings"; import { ALL_ITEMS_SELECTED } from "./selection-utils"; import { VirtualMcpFormSchema, type VirtualMcpFormData } from "./types"; import { VirtualMCPShareModal } from "./virtual-mcp-share-modal"; @@ -882,6 +884,7 @@ function VirtualMcpDetailViewWithData({ const addedConnectionIds = new Set(connections.map((c) => c.connection_id)); const { section } = useProjectSettingsSection(); + const hostedCms = useOrgFlagState("site_editor_content_protocol") === true; const views = useProjectViews({ virtualMcpId: virtualMcp.id, form, @@ -1193,6 +1196,14 @@ function VirtualMcpDetailViewWithData({ )} + {/* Hosted Deco CMS (Blocks v8): the site's telemetry tokens. */} + {section === "site" && hostedCms && hasRepository && ( + + )} + {/* Sandbox — the repo it clones and what it runs there. */} {section === "site" && ( diff --git a/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx b/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx new file mode 100644 index 0000000000..01acb275fb --- /dev/null +++ b/apps/web/src/views/virtual-mcp/settings/site-token-section.tsx @@ -0,0 +1,160 @@ +/** + * Site tokens of a hosted v8 site: what the site passes as + * `createCMS({ site, token })` to send telemetry. A token is shown once; + * issuing always works, and the list shows every token issued. + */ + +import { useState } from "react"; +import { Copy01, Key01, Plus } from "@untitledui/icons"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@decocms/ui/components/dialog.tsx"; +import { Input } from "@decocms/ui/components/input.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { + SettingsCard, + SettingsCardRow, + SettingsSection, +} from "@/components/settings/settings-section"; +import { useT } from "@/i18n/use-t.ts"; +import { formatTimeAgo } from "@/lib/format-time.ts"; +import { KEYS } from "@/lib/query-keys"; + +interface SiteTokenRecord { + kid: string; + iat: number; +} + +async function readJson(res: Response): Promise { + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); + return json as T; +} + +export function SiteTokenSection({ + orgSlug, + virtualMcpId, +}: { + orgSlug: string; + virtualMcpId: string; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const base = `/api/${orgSlug}/hosted/${encodeURIComponent(virtualMcpId)}/site-tokens`; + const key = KEYS.hostedSiteTokens(orgSlug, virtualMcpId); + const [created, setCreated] = useState(null); + + const list = useQuery({ + queryKey: key, + queryFn: async () => + readJson<{ site: string; tokens: SiteTokenRecord[] }>(await fetch(base)), + }); + const issue = useMutation({ + mutationFn: async () => + readJson<{ token: string }>(await fetch(base, { method: "POST" })), + onSuccess: ({ token }) => setCreated(token), + onError: (error) => toast.error(error.message), + onSettled: () => queryClient.invalidateQueries({ queryKey: key }), + }); + const tokens = list.data?.tokens ?? []; + + return ( + + + +
+
+

{t("siteTokens.siteId")}

+

+ {list.data?.site ?? "—"} +

+
+ +
+
+ {list.isError ? ( + +

{list.error.message}

+
+ ) : null} + {tokens.map((token) => ( + +
+ +
+

{token.kid}

+

+ {t("siteTokens.issuedAgo", { + when: formatTimeAgo(new Date(token.iat * 1000)), + })} +

+
+
+
+ ))} +
+ + !o && setCreated(null)} + > + + + {t("siteTokens.createdTitle")} + + {t("siteTokens.createdDescription")} + + +
+ + +
+ + + +
+
+
+ ); +} From ea39a2ff6185de061f0a60b72a64615a51e87e2a Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 12:04:04 -0300 Subject: [PATCH 2/3] fix(web): hosted Publish says "Published", in one toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted Publish ends in exactly one toast (sonner id site-editor-publish): "Published · Your changes are live.", "Nothing to publish", or, when the changes are saved but not live yet, "Saved, but not published yet" with Try again, which puts main's head live (POST releases/current {head:true}) and replaces the toast with the outcome. "Merged · Current on the CDN" and the other merge/CDN/Make current strings are gone. The two "Item updated successfully" toasts that followed every Publish came from the draft list's background writes (auto-naming the new draft, dropping the published one): useReleases now writes silently (useCollectionActions/useVirtualMCPActions take { silent }), and a failed clean-up after a successful Publish is logged, not toasted. This also quiets the Fast Preview Publish and the branch picker, which report their own failures. When nothing was published, the dialog says so in plain words ("Someone else published while you were publishing…", "Couldn't publish. Your changes are saved. Try again in a moment.") with the API's text behind Details. A hosted note defaults to "Changes by {name}" (never a branch name on the Versions screen); discard failures read "Couldn't discard. Try again." with Details; Publish waits until the content backend is known so a hosted site never takes the git path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/serve-save-error.test.ts | 14 +-- .../sections-editor/site-editor-toast.tsx | 4 +- .../thread/repository/cms-header-actions.tsx | 5 +- .../thread/repository/hosted-publish-api.ts | 31 +++-- .../hosted-publish-feedback.test.ts | 117 ++++++++++++++++++ .../repository/hosted-publish-feedback.ts | 80 ++++++++++++ .../thread/repository/publish-dialog.tsx | 20 ++- .../repository/use-cms-publish-actions.ts | 96 +++++++++----- .../thread/repository/use-releases.ts | 5 +- apps/web/src/i18n/en/site-editor.ts | 14 +++ apps/web/src/i18n/en/thread.ts | 11 -- apps/web/src/i18n/pt-br/site-editor.ts | 14 +++ apps/web/src/i18n/pt-br/thread.ts | 11 -- .../sdk/hooks/use-collections.silent.test.tsx | 54 ++++++++ apps/web/src/sdk/hooks/use-collections.ts | 21 +++- apps/web/src/sdk/hooks/use-virtual-mcp.ts | 10 +- 16 files changed, 419 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts create mode 100644 apps/web/src/components/thread/repository/hosted-publish-feedback.ts create mode 100644 apps/web/src/sdk/hooks/use-collections.silent.test.tsx 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 19d47b90b1..a46596ad27 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 @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; import { toast } from "sonner"; import { en } from "@/i18n/en/index.ts"; @@ -76,11 +76,8 @@ describe("save messages for a local deco serve (a developer)", () => { }); describe("one toast per save failure", () => { - afterEach(() => { - toast.dismiss(); - }); - test("a burst of failing autosaves shows a single toast", () => { + const before = toast.getToasts().length; for (let i = 0; i < 3; i++) { toastSaveError(t, new Error(`HTTP 50${i}`), "github"); } @@ -88,16 +85,14 @@ describe("one toast per save failure", () => { .getToasts() .filter((item) => item.id === SITE_EDITOR_SAVE_TOAST); expect(shown).toHaveLength(1); + expect(toast.getToasts().length - before).toBeLessThanOrEqual(1); }); }); describe("one toast per publish", () => { - afterEach(() => { - toast.dismiss(); - }); - test("a failure, its retry and the success share one toast", () => { let retried = 0; + const before = toast.getToasts().length; toastPublishFailed(t, { headline: "Couldn't publish this version.", detail: "latest-update-failed", @@ -123,6 +118,7 @@ describe("one toast per publish", () => { expect((shown[0] as { title?: unknown }).title).toBe("Published"); // The success doesn't keep the failure's "Try again". expect((shown[0] as { action?: unknown }).action).toBeUndefined(); + expect(toast.getToasts().length - before).toBeLessThanOrEqual(1); 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 a3fcabe5e7..baea225912 100644 --- a/apps/web/src/components/sections-editor/site-editor-toast.tsx +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -38,7 +38,7 @@ export function ErrorDetails({ } /** 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; @@ -60,7 +60,7 @@ export function toastSaveError( * Sonner merges an update into the toast with the same id, so an outcome * clears what an earlier one set (a failure's "Try again", its stay-open). */ -const FRESH_TOAST = { action: undefined, duration: undefined } as const; +export const FRESH_TOAST = { action: undefined, duration: undefined } as const; /** Published: what the site shows now. `description` says what went live. */ export function toastPublished(t: TFunction, description: string) { diff --git a/apps/web/src/components/thread/repository/cms-header-actions.tsx b/apps/web/src/components/thread/repository/cms-header-actions.tsx index 429cd7d1ae..5f32282d9f 100644 --- a/apps/web/src/components/thread/repository/cms-header-actions.tsx +++ b/apps/web/src/components/thread/repository/cms-header-actions.tsx @@ -251,9 +251,10 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { await deleteRelease(published); } }, - /** The dialog is already closed by now, so a toast is the only surface. */ + /** The publish already succeeded and showed its one toast; this clean-up + * is nothing the user can act on, so it is logged, not toasted. */ onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : String(err)); + console.error("site editor: settling after publish failed", err); }, }); diff --git a/apps/web/src/components/thread/repository/hosted-publish-api.ts b/apps/web/src/components/thread/repository/hosted-publish-api.ts index 8218fdab1e..518fe10861 100644 --- a/apps/web/src/components/thread/repository/hosted-publish-api.ts +++ b/apps/web/src/components/thread/repository/hosted-publish-api.ts @@ -2,8 +2,9 @@ * Publish for a hosted v8 site (Blocks v8 on GitHub): commits the project's * CDN draft straight to main, writes that commit's companion release and * makes it current on the CDN. `release` says how far the CDN steps got: - * `current`, `created` (making it current failed: Make current on Releases) - * or `none` (no release: the next Publish includes these changes). + * `current`, `created` (making it current failed) or `none` (no release was + * written). Either way the changes are saved on main, and + * {@link publishMainHead} retries the CDN steps ("Try again"). */ export type HostedPublishResult = @@ -21,30 +22,44 @@ export class HostedPublishError extends Error { } } -async function post(url: string, body: unknown): Promise { +async function post(url: string, body: unknown): Promise { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); - const json = (await res.json().catch(() => ({}))) as { - error?: string; - } & Partial; + const json = (await res.json().catch(() => ({}))) as { error?: string }; if (!res.ok) { throw new HostedPublishError( json.error ?? `HTTP ${res.status}`, json.error ?? null, ); } - return json as HostedPublishResult; + return json as T; } export function publishHostedDraft( params: { orgSlug: string; virtualMcpId: string; branch: string }, note: string, ): Promise { - return post( + return post( `/api/${params.orgSlug}/decofile/${encodeURIComponent(params.virtualMcpId)}/${encodeURIComponent(params.branch)}/publish`, { note }, ); } + +/** + * "Try again" after a Publish that saved the changes but didn't put them + * live: writes the companion release of main's head when it is missing and + * makes it current. Main's head holds every published change, this one's + * included, so it never rolls back a newer Publish. + */ +export function publishMainHead(params: { + orgSlug: string; + virtualMcpId: string; +}): Promise { + return post( + `/api/${params.orgSlug}/hosted/${encodeURIComponent(params.virtualMcpId)}/releases/current`, + { head: true }, + ); +} diff --git a/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts new file mode 100644 index 0000000000..860300f881 --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { toast } from "sonner"; +import { en } from "@/i18n/en/index.ts"; +import { interpolate } from "@/i18n/interpolate.ts"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { SITE_EDITOR_PUBLISH_TOAST } from "@/components/sections-editor/site-editor-toast.tsx"; +import { + HostedPublishError, + type HostedPublishResult, +} from "./hosted-publish-api.ts"; +import { + hostedPublishFailure, + notifyHostedPublish, + notifySavedNotPublished, +} from "./hosted-publish-feedback.ts"; + +const t: TFunction = (key, vars) => interpolate(en[key], vars); + +type Shown = { + id?: string | number; + title?: unknown; + type?: string; + action?: { label: string; onClick: (event: unknown) => void }; +}; +/** The publish toast(s) on screen. Sonner's state is global and outlives a + * test, so "one toast" is: one with the publish id, and at most one new. */ +const shown = () => + (toast.getToasts() as Shown[]).filter( + (item) => item.id === SITE_EDITOR_PUBLISH_TOAST, + ); +const total = () => toast.getToasts().length; + +const JARGON = [/\bCDN\b/i, /merged?/i, /\bcurrent\b/i, /\bmain\b/i, /commit/i]; + +describe("hosted Publish: one toast per Publish", () => { + const cases: [HostedPublishResult, string, boolean][] = [ + [ + { result: "merged", sha: "a".repeat(40), release: "current" }, + "Published", + false, + ], + [ + { result: "merged", sha: "a".repeat(40), release: "created" }, + "Saved, but not published yet", + true, + ], + [ + { result: "merged", sha: "a".repeat(40), release: "none" }, + "Saved, but not published yet", + true, + ], + [{ result: "up-to-date" }, "Nothing to publish", false], + ]; + + for (const [result, title, offersRetry] of cases) { + test(`${result.result}${"release" in result ? `/${result.release}` : ""} → "${title}"`, () => { + let retries = 0; + const before = total(); + notifyHostedPublish(t, result, () => { + retries++; + }); + expect(total() - before).toBeLessThanOrEqual(1); + const toasts = shown(); + expect(toasts).toHaveLength(1); + expect(toasts[0]!.id).toBe(SITE_EDITOR_PUBLISH_TOAST); + expect(toasts[0]!.title).toBe(title); + for (const word of JARGON) + expect(String(toasts[0]!.title)).not.toMatch(word); + if (offersRetry) { + expect(toasts[0]!.action?.label).toBe("Try again"); + toasts[0]!.action!.onClick({ defaultPrevented: false }); + expect(retries).toBe(1); + } else { + expect(toasts[0]!.action).toBeUndefined(); + } + }); + } + + test("a failed Try again replaces the toast, it doesn't stack", () => { + const retry = () => {}; + const before = total(); + notifyHostedPublish( + t, + { result: "merged", sha: "a".repeat(40), release: "created" }, + retry, + ); + notifySavedNotPublished(t, new HostedPublishError("HTTP 502", null), retry); + expect(total() - before).toBeLessThanOrEqual(1); + expect(shown()).toHaveLength(1); + expect(shown()[0]!.title).toBe("Saved, but not published yet"); + }); +}); + +describe("hosted Publish: the dialog's error", () => { + test("someone else published meanwhile", () => { + const failure = hostedPublishFailure( + t, + new HostedPublishError("main-moved", "main-moved"), + ); + expect(failure.message).toMatch(/Someone else published/); + expect(failure.detail).toBeNull(); + }); + + test("any other failure: plain words, the API's text only in Details", () => { + for (const raw of [ + "hosted delivery not configured", + "Project has no repository", + "HTTP 500", + ]) { + const failure = hostedPublishFailure(t, new HostedPublishError(raw, raw)); + expect(failure.message).toBe( + "Couldn't publish. Your changes are saved. Try again in a moment.", + ); + expect(failure.detail).toBe(raw); + } + }); +}); diff --git a/apps/web/src/components/thread/repository/hosted-publish-feedback.ts b/apps/web/src/components/thread/repository/hosted-publish-feedback.ts new file mode 100644 index 0000000000..c2c5b33526 --- /dev/null +++ b/apps/web/src/components/thread/repository/hosted-publish-feedback.ts @@ -0,0 +1,80 @@ +/** + * What a hosted Publish tells the user, in business words: one toast per + * Publish (sonner id `site-editor-publish`, shared with its "Try again" and + * with the Versions screen), and the dialog's inline error when nothing was + * published. "Published" means live; the API's codes and texts only ever + * reach the Details disclosure. + */ + +import { toast } from "sonner"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + FRESH_TOAST, + SITE_EDITOR_PUBLISH_TOAST, + errorDetail, + toastPublishFailed, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; +import { + HostedPublishError, + type HostedPublishResult, +} from "./hosted-publish-api.ts"; + +/** + * The one toast for a finished Publish. `retry` puts saved-but-not-live + * changes live (see `publishMainHead`); it is offered whenever the changes + * reached main but the site doesn't show them yet. + */ +export function notifyHostedPublish( + t: TFunction, + result: HostedPublishResult, + retry: () => void, +) { + if (result.result === "up-to-date") { + // toast.message, not toast(): only the typed calls replace a toast with + // the same id; a bare toast() always adds one. + toast.message(t("siteEditor.publish.nothingToPublish"), { + ...FRESH_TOAST, + id: SITE_EDITOR_PUBLISH_TOAST, + description: t("siteEditor.publish.everythingLive"), + }); + return; + } + if (result.release === "current") { + toastPublished(t, t("siteEditor.publish.changesLive")); + return; + } + notifySavedNotPublished(t, null, retry); +} + +/** The changes are saved but not live yet: a warning with "Try again". */ +export function notifySavedNotPublished( + t: TFunction, + error: unknown, + retry: () => void, +) { + toastPublishFailed(t, { + tone: "warning", + headline: t("siteEditor.publish.savedNotPublished"), + body: t("siteEditor.publish.savedNotPublishedBody"), + detail: errorDetail(error), + retry, + }); +} + +/** A Publish that published nothing: the dialog's inline error. */ +export function hostedPublishFailure( + t: TFunction, + error: unknown, +): { message: string; detail: string | null } { + if (error instanceof HostedPublishError && error.code === "main-moved") { + return { + message: t("siteEditor.publish.publishedMeanwhile"), + detail: null, + }; + } + return { + message: t("siteEditor.publish.failed"), + detail: errorDetail(error), + }; +} diff --git a/apps/web/src/components/thread/repository/publish-dialog.tsx b/apps/web/src/components/thread/repository/publish-dialog.tsx index 923db099bf..e1fcba4f28 100644 --- a/apps/web/src/components/thread/repository/publish-dialog.tsx +++ b/apps/web/src/components/thread/repository/publish-dialog.tsx @@ -74,6 +74,7 @@ import { useCmsPublishState } from "./use-cms-publish-state.ts"; import { useResolvedPublishGate } from "@/components/sandbox/hooks/use-publish-gate.ts"; import { useOptionalChatTask } from "@/components/chat/chat-context"; import { useContentBackend } from "@/components/sections-editor/use-content-backend.ts"; +import { ErrorDetails } from "@/components/sections-editor/site-editor-toast.tsx"; export type { CmsPublishMode }; @@ -508,6 +509,7 @@ function CmsPublishContent({ isPublishing, isDiscarding, publishError, + publishErrorDetail, submit, discardChange, discardAll, @@ -523,13 +525,19 @@ function CmsPublishContent({ onPullRequestChanged, onPublished, hosted, + authorName: session?.user?.name, }); const canDiscard = (paths: readonly string[]) => discardablePaths === null || paths.every((p) => discardablePaths.has(p)); + // Until the backend is known, Publish would take the git path on a hosted + // site (and speak git): wait for it. const canSubmit = - !isPublishing && summary.count > 0 && (isReview || gate.allowed); + backend.kind !== "pending" && + !isPublishing && + summary.count > 0 && + (isReview || gate.allowed); const headerTitle = isReview ? summary.count === 0 @@ -822,7 +830,15 @@ function CmsPublishContent({ )}
{publishError ? ( -

{publishError}

+
+

{publishError}

+ {publishErrorDetail ? ( + + ) : null} +
) : null} } diff --git a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts index 3d37c46ee7..e32d1dc7a3 100644 --- a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts +++ b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts @@ -22,10 +22,20 @@ import { } from "./publish-flow.ts"; import { discardGitFiles } from "./sandbox-git-api.ts"; import { - HostedPublishError, type HostedPublishResult, publishHostedDraft, + publishMainHead, } from "./hosted-publish-api.ts"; +import { + hostedPublishFailure, + notifyHostedPublish, + notifySavedNotPublished, +} from "./hosted-publish-feedback.ts"; +import { + errorDetail, + errorDetailsDescription, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; /** `publish` merges to production; `review` stops at the pull request. */ export type CmsPublishMode = "publish" | "review"; @@ -47,16 +57,20 @@ interface CmsPublishActionsArgs { onPublished?: () => void | Promise; /** * A hosted v8 site: publish commits the CDN draft to main, creates its - * release and makes it current (no pull request). It's done once merged; a - * release that isn't current is made current from the Releases screen. + * release and makes it current (no pull request). Saved once on main; when + * it isn't live yet, the toast's "Try again" makes main's head live. */ hosted?: boolean; + /** Who publishes: a hosted Publish's default version note names them. */ + authorName?: string | null; } interface CmsPublishActions { isPublishing: boolean; isDiscarding: boolean; publishError: string | undefined; + /** The developer detail behind `publishError`, for a Details disclosure. */ + publishErrorDetail: string | null; /** Publish or submit for review, per mode — the button never branches. */ submit: () => Promise; discardChange: (change: PublishChange) => Promise; @@ -78,34 +92,51 @@ export function useCmsPublishActions( onPullRequestChanged, onPublished, hosted = false, + authorName, } = args; const t = useT(); const queryClient = useQueryClient(); const [isPublishing, setIsPublishing] = useState(false); const [isDiscarding, setIsDiscarding] = useState(false); const [publishError, setPublishError] = useState(); + const [publishErrorDetail, setPublishErrorDetail] = useState( + null, + ); const noteParts = () => publishNoteParts( note, - t("thread.publishDialog.changesFrom", { branch: target.headBranch }), + // A hosted site's notes are the Versions screen's titles: never a + // branch name there. + hosted + ? authorName + ? t("siteEditor.publish.defaultNote", { name: authorName }) + : t("siteEditor.publish.defaultNoteAnonymous") + : t("thread.publishDialog.changesFrom", { branch: target.headBranch }), ); - /** Merged is done: the popover closes, saying what the CDN serves. */ - const settleHosted = async (result: HostedPublishResult) => { - if (result.result === "up-to-date") { - toast.success(t("thread.publishPopover.upToDate")); - } else if (result.release === "current") { - toast.success(t("thread.publishPopover.mergedCurrent")); - } else if (result.release === "created") { - toast.warning(t("thread.publishPopover.mergedNotCurrent")); - } else { - toast.warning(t("thread.publishPopover.mergedNoRelease")); - } - // A mounted Releases screen shows the new commit and what is Current. - void queryClient.invalidateQueries({ + const invalidateVersions = () => + queryClient.invalidateQueries({ queryKey: KEYS.hostedReleases(target.orgSlug, target.virtualMcpId), }); + + /** "Try again": put the saved changes live (main's head), same toast. */ + const goLive = async () => { + try { + await publishMainHead(target); + toastPublished(t, t("siteEditor.publish.changesLive")); + } catch (error) { + notifySavedNotPublished(t, error, () => void goLive()); + } finally { + void invalidateVersions(); + } + }; + + /** Saved on main is done: the popover closes with one toast. */ + const settleHosted = async (result: HostedPublishResult) => { + notifyHostedPublish(t, result, () => void goLive()); + // A mounted Versions screen shows the new version and what is live. + void invalidateVersions(); onOpenChange(false); await onPublished?.(); }; @@ -114,16 +145,13 @@ export function useCmsPublishActions( publishLockRef.current = true; setIsPublishing(true); setPublishError(undefined); + setPublishErrorDetail(null); try { await settleHosted(await publishHostedDraft(target, noteParts().message)); } catch (error) { - setPublishError( - error instanceof HostedPublishError && error.code === "main-moved" - ? t("thread.publishPopover.mainMoved") - : error instanceof Error - ? error.message - : t("thread.publishDialog.failedPublish"), - ); + const failure = hostedPublishFailure(t, error); + setPublishError(failure.message); + setPublishErrorDetail(failure.detail); await refresh(); } finally { publishLockRef.current = false; @@ -135,15 +163,15 @@ export function useCmsPublishActions( publishLockRef.current = true; setIsPublishing(true); setPublishError(undefined); + setPublishErrorDetail(null); try { await runPublishFlow(target, noteParts(), t); - toast.success( + toastPublished( + t, destinationHost - ? t("thread.publishPopover.publishedTo", { host: destinationHost }) - : t("thread.publishDialog.publishedTo", { - baseBranch: target.baseBranch, - }), + ? t("siteEditor.publish.liveOn", { host: destinationHost }) + : t("siteEditor.publish.changesLive"), ); onOpenChange(false); // Together: awaiting the PR re-read first let the stale open PR render. @@ -164,6 +192,7 @@ export function useCmsPublishActions( publishLockRef.current = true; setIsPublishing(true); setPublishError(undefined); + setPublishErrorDetail(null); try { const pr = await runSubmitForReviewFlow(target, noteParts()); @@ -198,11 +227,9 @@ export function useCmsPublishActions( toast.success(success); await refresh(); } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("thread.publishPopover.failedDiscard"), - ); + toast.error(t("siteEditor.discard.failed"), { + description: errorDetailsDescription(t, errorDetail(error)), + }); } finally { setIsDiscarding(false); } @@ -212,6 +239,7 @@ export function useCmsPublishActions( isPublishing, isDiscarding, publishError, + publishErrorDetail, // Hosted callers never pass review mode (no pull request to open). submit: mode === "review" ? submitForReview : hosted ? publishHosted : publish, diff --git a/apps/web/src/components/thread/repository/use-releases.ts b/apps/web/src/components/thread/repository/use-releases.ts index 12aa65a1c6..504cbaf9c1 100644 --- a/apps/web/src/components/thread/repository/use-releases.ts +++ b/apps/web/src/components/thread/repository/use-releases.ts @@ -51,7 +51,10 @@ type ItemData = { item: VirtualMCPEntity | null }; /** Curated branch-backed release list at `metadata.releases`; discard drops only the entry, leaving the remote branch. */ export function useReleases(virtualMcpId: string) { const vm = useVirtualMCP(virtualMcpId); - const actions = useVirtualMCPActions(); + // Silent: these writes follow a user action (Publish, a rename) that has + // its own one toast, or happen in the background (auto-naming a draft). + // Callers report a failure themselves when the user can act on it. + const actions = useVirtualMCPActions({ silent: true }); const { org } = useProjectContext(); const queryClient = useQueryClient(); const releases: Release[] = vm?.metadata?.releases ?? []; diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index 921ec2b6b5..ce19ed4f0e 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -8,6 +8,20 @@ export const siteEditor = { "siteEditor.details": "Details", "siteEditor.tryAgain": "Try again", "siteEditor.publish.published": "Published", + "siteEditor.publish.changesLive": "Your changes are live.", + "siteEditor.publish.liveOn": "Your changes are live on {host}.", + "siteEditor.publish.nothingToPublish": "Nothing to publish", + "siteEditor.publish.everythingLive": "Everything is already live.", + "siteEditor.publish.savedNotPublished": "Saved, but not published yet", + "siteEditor.publish.savedNotPublishedBody": + "Your changes are safe. Try again to put them live.", + "siteEditor.publish.publishedMeanwhile": + "Someone else published while you were publishing, so nothing changed. Review your changes and publish again.", + "siteEditor.publish.failed": + "Couldn't publish. Your changes are saved. Try again in a moment.", + "siteEditor.publish.defaultNote": "Changes by {name}", + "siteEditor.publish.defaultNoteAnonymous": "Site update", + "siteEditor.discard.failed": "Couldn't discard. Try again.", "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/en/thread.ts b/apps/web/src/i18n/en/thread.ts index b55f708205..61124e8a0b 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -146,20 +146,11 @@ export const thread = { "thread.mergeRefused.notFound": "It no longer exists.", "thread.mergeRefused.error": "Failed to merge.", "thread.publishDialog.openingComparison": "Opening the comparison…", - "thread.publishDialog.publishedTo": "Published to {baseBranch}", "thread.publishDialog.submittedForReview": "Submitted pull request #{prNumber} for review", "thread.publishDialog.viewOnProvider": "View on provider", "thread.publishDialog.viewPr": "View PR", "thread.publishPopover.blocksGroup": "Blocks", - "thread.publishPopover.mergedCurrent": "Merged · Current on the CDN", - "thread.publishPopover.mergedNotCurrent": - "Merged · release created, making it current failed — use Make current on Releases", - "thread.publishPopover.mergedNoRelease": - "Merged · no release created (the next Publish includes these changes)", - "thread.publishPopover.upToDate": "Up to date, nothing to publish", - "thread.publishPopover.mainMoved": - "Main changed while publishing, so nothing was published. Publish again.", "thread.publishPopover.branchMoved": "This branch changed since these changes were shown. Close and reopen to review what will be published.", "thread.publishPopover.detailsUnavailable": @@ -173,7 +164,6 @@ export const thread = { "thread.publishPopover.chipRemoved": "Removed", "thread.publishPopover.emptyHint": "Your latest changes are already live.", "thread.publishPopover.everythingLive": "Everything is published", - "thread.publishPopover.failedDiscard": "Failed to discard changes", "thread.publishPopover.globalSection": "Global section", "thread.publishPopover.lastPublished": "Last published {when}", "thread.publishPopover.lastPublishedBy": "Last published {when} by {name}", @@ -197,7 +187,6 @@ export const thread = { "thread.publishPopover.publishOneInProduction": "Publish 1 change in production", "thread.publishPopover.publishOne": "Publish 1 change", - "thread.publishPopover.publishedTo": "Published to {host}", "thread.publishPopover.publishing": "Publishing…", "thread.publishPopover.requestApproval": "Request approval", "thread.publishPopover.discarded": "Discarded {name}", diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index e2813e0006..24b24c7bb6 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -4,6 +4,20 @@ export const siteEditor = { "siteEditor.details": "Detalhes", "siteEditor.tryAgain": "Tentar de novo", "siteEditor.publish.published": "Publicado", + "siteEditor.publish.changesLive": "Suas alterações estão no ar.", + "siteEditor.publish.liveOn": "Suas alterações estão no ar em {host}.", + "siteEditor.publish.nothingToPublish": "Nada para publicar", + "siteEditor.publish.everythingLive": "Tudo já está no ar.", + "siteEditor.publish.savedNotPublished": "Salvo, mas ainda não publicado", + "siteEditor.publish.savedNotPublishedBody": + "Suas alterações estão guardadas. Tente de novo para colocá-las no ar.", + "siteEditor.publish.publishedMeanwhile": + "Outra pessoa publicou enquanto você publicava, então nada mudou. Revise suas alterações e publique de novo.", + "siteEditor.publish.failed": + "Não foi possível publicar. Suas alterações estão salvas. Tente de novo em instantes.", + "siteEditor.publish.defaultNote": "Alterações de {name}", + "siteEditor.publish.defaultNoteAnonymous": "Atualização do site", + "siteEditor.discard.failed": "Não foi possível descartar. Tente de novo.", "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/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 25c4263c86..18692dcb0f 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -153,20 +153,11 @@ export const thread = { "thread.mergeRefused.notFound": "Não existe mais.", "thread.mergeRefused.error": "Falha ao fazer merge.", "thread.publishDialog.openingComparison": "Abrindo a comparação…", - "thread.publishDialog.publishedTo": "Publicado em {baseBranch}", "thread.publishDialog.submittedForReview": "Pull request #{prNumber} enviado para revisão", "thread.publishDialog.viewOnProvider": "Ver no provedor", "thread.publishDialog.viewPr": "Ver PR", "thread.publishPopover.blocksGroup": "Blocos", - "thread.publishPopover.mergedCurrent": "Mesclado · Atual na CDN", - "thread.publishPopover.mergedNotCurrent": - "Mesclado · versão criada, mas não foi possível torná-la atual — use Tornar atual em Versões", - "thread.publishPopover.mergedNoRelease": - "Mesclado · nenhuma versão criada (a próxima publicação inclui estas alterações)", - "thread.publishPopover.upToDate": "Tudo atualizado, nada para publicar", - "thread.publishPopover.mainMoved": - "A main mudou durante a publicação, então nada foi publicado. Publique novamente.", "thread.publishPopover.branchMoved": "Esta branch mudou depois que estas alterações foram exibidas. Feche e abra novamente para revisar o que será publicado.", "thread.publishPopover.detailsUnavailable": @@ -182,7 +173,6 @@ export const thread = { "thread.publishPopover.emptyHint": "Suas alterações mais recentes já estão no ar.", "thread.publishPopover.everythingLive": "Tudo publicado", - "thread.publishPopover.failedDiscard": "Falha ao descartar alterações", "thread.publishPopover.globalSection": "Seção global", "thread.publishPopover.lastPublished": "Última publicação {when}", "thread.publishPopover.lastPublishedBy": @@ -208,7 +198,6 @@ export const thread = { "thread.publishPopover.publishOneInProduction": "Publicar 1 alteração em produção", "thread.publishPopover.publishOne": "Publicar 1 alteração", - "thread.publishPopover.publishedTo": "Publicado em {host}", "thread.publishPopover.publishing": "Publicando…", "thread.publishPopover.requestApproval": "Pedir aprovação", "thread.publishPopover.discarded": "{name} descartado", diff --git a/apps/web/src/sdk/hooks/use-collections.silent.test.tsx b/apps/web/src/sdk/hooks/use-collections.silent.test.tsx new file mode 100644 index 0000000000..3d676b730a --- /dev/null +++ b/apps/web/src/sdk/hooks/use-collections.silent.test.tsx @@ -0,0 +1,54 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); +import { describe, expect, it } from "bun:test"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; +import { useCollectionActions } from "./use-collections"; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return {children}; +} + +const fakeClient = (fail: boolean) => + ({ + callTool: async () => + fail + ? { isError: true, content: [{ type: "text", text: "boom" }] } + : { structuredContent: { item: { id: "vm" } } }, + }) as unknown as Client; + +async function runUpdate(silent: boolean, fail: boolean) { + const { result } = renderHook( + () => + useCollectionActions("org", "VIRTUAL_MCP", fakeClient(fail), { + silent, + }), + { wrapper }, + ); + await act(async () => { + await result.current.update + .mutateAsync({ id: "vm", data: {} }) + .catch(() => {}); + }); +} + +describe("useCollectionActions toasts", () => { + it("toasts an update by default", async () => { + const before = toast.getToasts().length; + await runUpdate(false, false); + expect(toast.getToasts().length - before).toBe(1); + }); + + it("silent: a background update says nothing, success or failure", async () => { + const before = toast.getToasts().length; + await runUpdate(true, false); + await runUpdate(true, true); + expect(toast.getToasts().length - before).toBe(0); + }); +}); diff --git a/apps/web/src/sdk/hooks/use-collections.ts b/apps/web/src/sdk/hooks/use-collections.ts index bab1333f83..cf35a6a7bd 100644 --- a/apps/web/src/sdk/hooks/use-collections.ts +++ b/apps/web/src/sdk/hooks/use-collections.ts @@ -486,13 +486,22 @@ export function buildCollectionQueryKey( * @param scopeKey - The scope key (connectionId for connection-scoped, virtualMcpId for virtual-mcp-scoped, etc.) * @param collectionName - The name of the collection (e.g., "CONNECTIONS", "AGENT") * @param client - The MCP client used to call collection tools + * @param options.silent - No success/failure toasts: the caller owns the + * feedback (a background write the user didn't ask for says nothing). * @returns Object with create, update, and delete mutation hooks */ export function useCollectionActions( scopeKey: string, collectionName: string, client: Client, + options: { silent?: boolean } = {}, ) { + const notify = options.silent + ? { success: () => {}, error: () => {} } + : { + success: (message: string) => toast.success(message), + error: (message: string) => toast.error(message), + }; const queryClient = useQueryClient(); const upperName = collectionName.toUpperCase(); const createToolName = `COLLECTION_${upperName}_CREATE`; @@ -533,11 +542,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemCreatedSuccessfully")); + notify.success(getToastMessage("itemCreatedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("createItemFailed", { error: message })); + notify.error(getToastMessage("createItemFailed", { error: message })); }, }); @@ -553,11 +562,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemUpdatedSuccessfully")); + notify.success(getToastMessage("itemUpdatedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("updateItemFailed", { error: message })); + notify.error(getToastMessage("updateItemFailed", { error: message })); }, }); @@ -573,11 +582,11 @@ export function useCollectionActions( }, onSuccess: () => { invalidateCollection(); - toast.success(getToastMessage("itemDeletedSuccessfully")); + notify.success(getToastMessage("itemDeletedSuccessfully")); }, onError: (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - toast.error(getToastMessage("deleteItemFailed", { error: message })); + notify.error(getToastMessage("deleteItemFailed", { error: message })); }, }); diff --git a/apps/web/src/sdk/hooks/use-virtual-mcp.ts b/apps/web/src/sdk/hooks/use-virtual-mcp.ts index d0daceb41c..05ee487fb6 100644 --- a/apps/web/src/sdk/hooks/use-virtual-mcp.ts +++ b/apps/web/src/sdk/hooks/use-virtual-mcp.ts @@ -269,9 +269,10 @@ export function useVirtualMCPsLastUsed(ids: string[]) { /** * Hook to get virtual MCP mutation actions (create, update, delete) * + * @param options.silent - No toasts: the caller owns the feedback. * @returns Object with create, update, and delete mutation hooks */ -export function useVirtualMCPActions() { +export function useVirtualMCPActions(options: { silent?: boolean } = {}) { const { org } = useProjectContext(); const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -279,5 +280,10 @@ export function useVirtualMCPActions() { orgSlug: org.slug, }); - return useCollectionActions(org.id, "VIRTUAL_MCP", client); + return useCollectionActions( + org.id, + "VIRTUAL_MCP", + client, + options, + ); } From e0287a495cb3130717c1955245e8d2dc778c0621 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 12:42:04 -0300 Subject: [PATCH 3/3] fix(web): the git-backed Publish speaks business words too, in one message A failed Publish on a Fast Preview site no longer headlines "Failed to rebase/push/merge" or "This branch changed": the dialog says it couldn't publish (changes are saved) or that the changes were updated meanwhile, with the step's error and a left-open pull request behind Details. No toast stacks on top of the dialog's error. "Last published" speaks the viewer's language; pt-br wording aligned ("salvas", "Tentar de novo"). A failed follow-up after a finished Publish no longer runs its failure branch. thread.publishPopover.* joins the jargon test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../hosted-publish-feedback.test.ts | 5 ++- .../thread/repository/publish-dialog.tsx | 6 ++- .../thread/repository/publish-flow.ts | 31 ++------------ .../repository/use-cms-publish-actions.ts | 40 +++++++++++++++---- apps/web/src/i18n/en/site-editor.ts | 2 + apps/web/src/i18n/en/thread.ts | 3 -- apps/web/src/i18n/pt-br/site-editor.ts | 4 +- apps/web/src/i18n/pt-br/thread.ts | 5 +-- apps/web/src/i18n/site-editor-copy.test.ts | 1 + 9 files changed, 51 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts index 860300f881..6bd02ded59 100644 --- a/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts +++ b/apps/web/src/components/thread/repository/hosted-publish-feedback.test.ts @@ -68,8 +68,11 @@ describe("hosted Publish: one toast per Publish", () => { expect(String(toasts[0]!.title)).not.toMatch(word); if (offersRetry) { expect(toasts[0]!.action?.label).toBe("Try again"); - toasts[0]!.action!.onClick({ defaultPrevented: false }); + toasts[0]!.action!.onClick({ preventDefault: () => {} }); expect(retries).toBe(1); + // The same toast stays, as "Publishing…", until the outcome. + expect(shown()).toHaveLength(1); + expect(shown()[0]!.title).toBe("Publishing…"); } else { expect(toasts[0]!.action).toBeUndefined(); } diff --git a/apps/web/src/components/thread/repository/publish-dialog.tsx b/apps/web/src/components/thread/repository/publish-dialog.tsx index e1fcba4f28..474a2501ec 100644 --- a/apps/web/src/components/thread/repository/publish-dialog.tsx +++ b/apps/web/src/components/thread/repository/publish-dialog.tsx @@ -38,7 +38,8 @@ import { ErrorBoundary } from "@/components/error-boundary.tsx"; import { useT, type TFunction } from "@/i18n/use-t.ts"; import { authClient } from "@/lib/auth-client.ts"; import { coAuthorFromSessionUser } from "@/lib/co-author-identity.ts"; -import { formatTimeAgo } from "@/lib/format-time.ts"; +import { usePreferences } from "@/hooks/use-preferences.ts"; +import { formatRelativeTime } from "@/lib/format-time.ts"; import { lastPreviewPageKey, readLastPreviewPage, @@ -396,6 +397,7 @@ function CmsPublishContent({ publishLockRef: React.MutableRefObject; }) { const t = useT(); + const [{ language }] = usePreferences(); /** The session publishing — the git routes resolve their runtime from it. */ const threadId = useOptionalChatTask()?.taskId ?? null; const { data: session } = authClient.useSession(); @@ -568,7 +570,7 @@ function CmsPublishContent({ } const pr = lastPublishedPr; if (!pr?.mergedAt) return null; - const when = formatTimeAgo(new Date(pr.mergedAt)); + const when = formatRelativeTime(new Date(pr.mergedAt), language); const name = lastPublishAttribution(pr); return name ? t("thread.publishPopover.lastPublishedBy", { when, name }) diff --git a/apps/web/src/components/thread/repository/publish-flow.ts b/apps/web/src/components/thread/repository/publish-flow.ts index 64517665d8..f49ea2eb01 100644 --- a/apps/web/src/components/thread/repository/publish-flow.ts +++ b/apps/web/src/components/thread/repository/publish-flow.ts @@ -210,8 +210,8 @@ function openPullRequest( /** * push → sync → open (or update) the pull request → squash-merge. Every failure - * arrives as a {@link PublishStepError}; hand it to {@link reportPublishFailure} - * for the presentation both surfaces share. + * arrives as a {@link PublishStepError}; read it with + * {@link describePublishFailure}. */ export async function runPublishFlow( target: PublishTarget, @@ -283,7 +283,7 @@ export function describePublishFailure( ): PublishFailure { if (error instanceof PublishHeadMovedError) { return { - message: t("thread.publishPopover.branchMoved"), + message: t("siteEditor.publish.changedMeanwhile"), pullRequest: null, headMoved: true, }; @@ -308,31 +308,6 @@ export function describePublishFailure( }; } -/** - * {@link describePublishFailure} plus the toast both surfaces raise for it. - * `pullRequestOpened` refreshes PR state; `headMoved` re-reads the change list. - */ -export function reportPublishFailure( - error: unknown, - t: TFunction, -): { message: string; pullRequestOpened: boolean; headMoved: boolean } { - const failure = describePublishFailure(error, t); - const pr = failure.pullRequest; - if (pr) { - toast.error(failure.message, { - action: { - label: t("thread.publishDialog.viewPr"), - onClick: () => window.open(pr.htmlUrl, "_blank", "noopener,noreferrer"), - }, - }); - } - return { - message: failure.message, - pullRequestOpened: pr !== null, - headMoved: failure.headMoved, - }; -} - /** Both surfaces confirm a review submission the same way: PR number + link. */ export function notifySubmittedForReview( pr: CreatedPullRequest, diff --git a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts index e32d1dc7a3..d7ce110798 100644 --- a/apps/web/src/components/thread/repository/use-cms-publish-actions.ts +++ b/apps/web/src/components/thread/repository/use-cms-publish-actions.ts @@ -13,9 +13,9 @@ import { useT } from "@/i18n/use-t.ts"; import { KEYS } from "@/lib/query-keys.ts"; import type { PublishChange } from "./publish-change-summary.ts"; import { + describePublishFailure, notifySubmittedForReview, publishNoteParts, - reportPublishFailure, runPublishFlow, runSubmitForReviewFlow, type PublishTarget, @@ -115,6 +115,15 @@ export function useCmsPublishActions( : t("thread.publishDialog.changesFrom", { branch: target.headBranch }), ); + /** A follow-up after a finished Publish: its failure isn't the Publish's. */ + const quietly = async (followUp?: () => void | Promise) => { + try { + await followUp?.(); + } catch { + // The Publish already succeeded (and said so); a stale list re-reads. + } + }; + const invalidateVersions = () => queryClient.invalidateQueries({ queryKey: KEYS.hostedReleases(target.orgSlug, target.virtualMcpId), @@ -138,7 +147,7 @@ export function useCmsPublishActions( // A mounted Versions screen shows the new version and what is live. void invalidateVersions(); onOpenChange(false); - await onPublished?.(); + await quietly(onPublished); }; const publishHosted = async () => { @@ -175,11 +184,28 @@ export function useCmsPublishActions( ); onOpenChange(false); // Together: awaiting the PR re-read first let the stale open PR render. - await Promise.all([onPullRequestChanged?.(), onPublished?.()]); + await Promise.all([quietly(onPullRequestChanged), quietly(onPublished)]); } catch (error) { - const failure = reportPublishFailure(error, t); - setPublishError(failure.message); - if (failure.pullRequestOpened) await onPullRequestChanged?.(); + // One message in the dialog, in plain words (no toast on top of it); + // the step's own error, and the pull request a failed merge left + // open, go behind Details. + const failure = describePublishFailure(error, t); + setPublishError( + failure.headMoved ? failure.message : t("siteEditor.publish.failed"), + ); + setPublishErrorDetail( + failure.headMoved + ? null + : [ + failure.pullRequest + ? `#${failure.pullRequest.number} ${failure.pullRequest.htmlUrl}` + : null, + errorDetail(error), + ] + .filter(Boolean) + .join("\n") || null, + ); + if (failure.pullRequest) await quietly(onPullRequestChanged); // Nothing was published — re-read so the list matches the new head. if (failure.headMoved) await refresh(); } finally { @@ -200,7 +226,7 @@ export function useCmsPublishActions( onOpenChange(false); await onPullRequestChanged?.(); } catch (error) { - const failure = reportPublishFailure(error, t); + const failure = describePublishFailure(error, t); setPublishError( failure.message || t("thread.publishDialog.failedSubmitForReview"), ); diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index f2848de7be..504b6562cb 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -17,6 +17,8 @@ export const siteEditor = { "Your changes are safe. Try again to put them live.", "siteEditor.publish.publishedMeanwhile": "Someone else published while you were publishing, so nothing changed. Review your changes and publish again.", + "siteEditor.publish.changedMeanwhile": + "Your changes were updated while you were reviewing them. Close and reopen to see what will be published.", "siteEditor.publish.failed": "Couldn't publish. Your changes are saved. Try again in a moment.", "siteEditor.publish.defaultNote": "Changes by {name}", diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 8073076526..fb6a04acc9 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -149,10 +149,7 @@ export const thread = { "thread.publishDialog.submittedForReview": "Submitted pull request #{prNumber} for review", "thread.publishDialog.viewOnProvider": "View on provider", - "thread.publishDialog.viewPr": "View PR", "thread.publishPopover.blocksGroup": "Blocks", - "thread.publishPopover.branchMoved": - "This branch changed since these changes were shown. Close and reopen to review what will be published.", "thread.publishPopover.detailsUnavailable": "Details for these changes could not be loaded.", "thread.publishPopover.loadFailed": "Couldn't load your changes", diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index 240d166b20..eb0982d76e 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -10,9 +10,11 @@ export const siteEditor = { "siteEditor.publish.everythingLive": "Tudo já está no ar.", "siteEditor.publish.savedNotPublished": "Salvo, mas ainda não publicado", "siteEditor.publish.savedNotPublishedBody": - "Suas alterações estão guardadas. Tente de novo para colocá-las no ar.", + "Suas alterações estão salvas. Tente de novo para colocá-las no ar.", "siteEditor.publish.publishedMeanwhile": "Outra pessoa publicou enquanto você publicava, então nada mudou. Revise suas alterações e publique de novo.", + "siteEditor.publish.changedMeanwhile": + "Suas alterações mudaram enquanto você revisava. Feche e abra de novo para ver o que será publicado.", "siteEditor.publish.failed": "Não foi possível publicar. Suas alterações estão salvas. Tente de novo em instantes.", "siteEditor.publish.defaultNote": "Alterações de {name}", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index b6d5f86196..7126907a18 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -156,15 +156,12 @@ export const thread = { "thread.publishDialog.submittedForReview": "Pull request #{prNumber} enviado para revisão", "thread.publishDialog.viewOnProvider": "Ver no provedor", - "thread.publishDialog.viewPr": "Ver PR", "thread.publishPopover.blocksGroup": "Blocos", - "thread.publishPopover.branchMoved": - "Esta branch mudou depois que estas alterações foram exibidas. Feche e abra novamente para revisar o que será publicado.", "thread.publishPopover.detailsUnavailable": "Não foi possível carregar os detalhes destas alterações.", "thread.publishPopover.loadFailed": "Não foi possível carregar suas alterações", - "thread.publishPopover.retry": "Tentar novamente", + "thread.publishPopover.retry": "Tentar de novo", "thread.publishPopover.showingFirst": "Mostrando as primeiras {shown} de {total} alterações", "thread.publishPopover.chipEdited": "Editado", diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts index 279115eced..1ce1397daf 100644 --- a/apps/web/src/i18n/site-editor-copy.test.ts +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -13,6 +13,7 @@ const SITE_EDITOR_PREFIXES = [ "releases.", "common.mainPanelTabs.releases", "projects.apps.releases", + "thread.publishPopover.", ]; const BANNED: RegExp[] = [