From e0a78aa34e2bd249daf5234762e3318cce8622b6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 14:46:43 -0300 Subject: [PATCH 1/8] feat(fast-preview): smart primary action button for CMS mode Fast Preview is sandbox-less and content-only: no daemon, no coding agent, no chat. It nonetheless rendered the vibecoding header cascade, where 5 of 21 states dispatch chat prompts into a composer Fast Preview disables (dead clicks) and 7 more can never match (lifecycle is pinned to running, workingTreeDirty and unpushed are hardcoded). Give it its own 7-state machine and one split button, in the editor's vocabulary rather than git's. - packages/ui: new SplitButton (on the existing unused ButtonGroup) and a `warning` Button variant using the existing --warning tokens. `disabled` disables only the primary half, so "Up to date" stays an inert pill whose menu still offers "Get latest". - cms-panel-state.ts: pure selector, 40 unit tests. Check state is encoded as colour (outcome) plus motion (progress): brand normally, warning when a check is not passing, spinner where the editor must wait, pulse where they can still act. - cms-header-actions.tsx: renderer reusing PublishDialog unchanged. - The branch lives at the mount point, so Fast Preview never mounts useSandboxEvents, useSandboxLifecycle or usePublishGate. usePublishGate is dropped from this path rather than disabled: canPublishDirectly defers to isDecoOnlyDiff under every policy, and a Fast Preview diff is deco-only by construction, so it always allowed. That also removes its 10s GitHub poll. The vibecoding cascade is untouched; its 39 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 315 +++++++++ .../thread/github/cms-panel-state.test.ts | 621 ++++++++++++++++++ .../thread/github/cms-panel-state.ts | 298 +++++++++ .../components/thread/github/panel-state.ts | 6 +- apps/web/src/i18n/en/thread.ts | 12 + apps/web/src/i18n/pt-br/thread.ts | 13 + .../web/src/views/virtual-mcp/header-info.tsx | 14 +- packages/ui/src/components/button.stories.tsx | 2 + packages/ui/src/components/button.tsx | 2 + .../src/components/split-button.stories.tsx | 101 +++ packages/ui/src/components/split-button.tsx | 169 +++++ packages/ui/src/styles/global.css | 16 + 12 files changed, 1566 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/thread/github/cms-header-actions.tsx create mode 100644 apps/web/src/components/thread/github/cms-panel-state.test.ts create mode 100644 apps/web/src/components/thread/github/cms-panel-state.ts create mode 100644 packages/ui/src/components/split-button.stories.tsx create mode 100644 packages/ui/src/components/split-button.tsx diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx new file mode 100644 index 0000000000..1210e7e998 --- /dev/null +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -0,0 +1,315 @@ +/** + * Header actions for **Fast Preview** (CMS) mode — the sandbox-less, + * content-only editing surface. + * + * `HeaderActions` is the vibecoding renderer: it mounts the sandbox event + * stream, the sandbox lifecycle and the publish gate, and five of its states + * dispatch chat prompts. Fast Preview has none of those — no daemon, no coding + * agent, no chat — so it renders this component instead, driven by the + * {@link selectCmsHeaderButton} state machine and a single split button. + * + * The branch happens at the mount point (`VirtualMcpHeaderInfo`) rather than + * inside `HeaderActions`, so in Fast Preview the sandbox hooks never mount. + */ + +import type { BranchMeta } from "@decocms/sandbox/shared"; +import { + branchUserLabel, + generateBranchName, +} from "@decocms/shared/branch-name"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + SplitButton, + type SplitButtonMenuItem, +} from "@decocms/ui/components/split-button.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useT } from "@/i18n/use-t"; +import { authClient } from "@/lib/auth-client.ts"; +import { resolveGithubAttachment } from "@/lib/github-repo.ts"; +import { KEYS } from "@/lib/query-keys"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { resolveFastPreview } from "@/sdk/fast-preview"; +import { useChatTask } from "../../chat/index"; +import { selectCmsHeaderButton, type CmsAction } from "./cms-panel-state.ts"; +import { isPrStateActivelyLoading } from "./panel-state.ts"; +import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; +import { + fetchGitStatus, + normalizePublishPolicy, + readGitHeadBranch, + rebaseGitBranch, + sandboxGitStatusQueryKey, +} from "./sandbox-git-api.ts"; +import { useChecks, usePrByBranch } from "./use-pr-data.ts"; +import { usePrReviews } from "./use-pr-reviews.ts"; + +interface Props { + virtualMcpId: string; +} + +export function CmsHeaderActions({ virtualMcpId }: Props) { + const t = useT(); + const { org } = useProjectContext(); + const queryClient = useQueryClient(); + const { data: session } = authClient.useSession(); + const vm = useVirtualMCP(virtualMcpId); + const { currentBranch: branch, setCurrentTaskBranch } = useChatTask(); + const [publishOpen, setPublishOpen] = useState(false); + const [dialogIntent, setDialogIntent] = + useState("publish-only"); + + const attachment = resolveGithubAttachment(vm); + const githubRepo = + attachment.status === "attached" || attachment.status === "public-clone" + ? attachment.repo + : null; + const { previewServerUrl } = resolveFastPreview(vm?.metadata); + + /** + * Sandbox-less: no daemon → no `branch` SSE event. Fetch the (GitHub-backed) + * status route into the same BranchMeta shape — but never on an interval: + * every call forwards to the GitHub API, and a timer here burns rate limit + * for data that only changes when WE commit. The only in-app mutation is the + * decofile PATCH, whose save hooks invalidate this key; external pushes are + * picked up on window focus. + */ + const statusQuery = useQuery({ + queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, branch ?? ""), + queryFn: () => fetchGitStatus(org.slug, virtualMcpId, branch ?? ""), + enabled: !!branch, + staleTime: 15_000, + }); + const status = statusQuery.data ?? null; + const branchMeta: BranchMeta = status + ? { + kind: "ready", + branch: readGitHeadBranch(status) ?? branch ?? "", + base: status.base ?? "main", + workingTreeDirty: false, + unpushed: 0, + aheadOfBase: status.aheadOfBase ?? 0, + behindBase: status.behindBase ?? 0, + headSha: status.headSha ?? "", + } + : { kind: "unknown" }; + + const githubHeadBranch = + (branchMeta.kind === "ready" ? branchMeta.branch : null) ?? branch ?? null; + const baseBranch = branchMeta.kind === "ready" ? branchMeta.base : "main"; + + const prQuery = usePrByBranch({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + branch: githubHeadBranch, + }); + const pr = prQuery.data ?? null; + + const checksQuery = useChecks({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + prNumber: pr && pr.state === "open" ? pr.number : null, + }); + + const reviewsQuery = usePrReviews({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + prNumber: pr && pr.state === "open" ? pr.number : null, + }); + + const refreshPrState = async () => { + await Promise.all([ + prQuery.refetch(), + checksQuery.refetch(), + reviewsQuery.refetch(), + ]); + }; + + /** + * A squash-merge leaves the published commits on the branch, so the editor + * has to move to a fresh one or the next edit would re-publish work that is + * already live. Modelled as a mutation so `isPending` — not a hand-rolled + * flag — is what tells the state machine a publish is still settling. + */ + const publishCompletion = useMutation({ + mutationFn: async () => { + await setCurrentTaskBranch( + generateBranchName(branchUserLabel(session?.user)), + ); + }, + /** The dialog is already closed by now, so a toast is the only surface. */ + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); + + const getLatest = useMutation({ + mutationFn: async (target: { branch: string; base: string }) => { + await rebaseGitBranch( + org.slug, + virtualMcpId, + target.branch, + target.base, + { onConflict: "branch-wins" }, + ); + return target; + }, + /** Invalidates drift AND the editor's content: the merge moved the head. */ + onSuccess: async (target) => { + toast.success( + t("thread.headerActions.syncedWithBase", { base: target.base }), + ); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey( + org.slug, + virtualMcpId, + branch ?? target.branch, + ), + }), + queryClient.invalidateQueries({ + queryKey: KEYS.decofile( + `${org.slug}/${virtualMcpId}/${target.branch}`, + ), + }), + ]); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); + + /** + * Detached: repo linked via a GitHub connection that's no longer aggregated. + * Render a reconnect pill instead of nothing so the user has a recovery path. + */ + if (attachment.status === "detached") { + return ( + + + + + + + + + {t("thread.headerActions.githubConnectionRemoved")} + + + + ); + } + if (!githubRepo) return null; + + /** + * Only the tail of the publish — the branch switch that follows the merge. + * + * While the dialog is open it owns the progress UI for its own + * push → rebase → open PR → squash-merge sequence, and it sits over this + * button. Treating "dialog open" as publishing would label the button + * "Publishing…" the whole time the editor is still *reading* the diff and + * deciding, which is precisely what "Review & Publish" promises they get to + * do first. + */ + const publishing = publishCompletion.isPending; + + const button = selectCmsHeaderButton({ + branch: branchMeta, + pr, + checks: checksQuery.data ?? [], + reviews: reviewsQuery.data ?? null, + publishing, + loading: isPrStateActivelyLoading(prQuery), + t, + }); + + const openDialog = (intent: PublishDialogIntent) => { + setDialogIntent(intent); + setPublishOpen(true); + }; + + const dispatch = (action: CmsAction) => { + switch (action) { + case "publish": + openDialog("publish-only"); + return; + case "request-approval": + openDialog("open-pr"); + return; + case "get-latest": + if (!githubHeadBranch || getLatest.isPending) return; + getLatest.mutate({ branch: githubHeadBranch, base: baseBranch }); + return; + case "open-pr": + if (pr?.htmlUrl) { + window.open(pr.htmlUrl, "_blank", "noopener,noreferrer"); + } + return; + } + }; + + const items: SplitButtonMenuItem[] = button.menu.map((item) => ({ + key: item.key, + label: item.label, + ...(item.tooltip ? { tooltip: item.tooltip } : {}), + onSelect: () => dispatch(item.action), + })); + + const action = button.action; + + return ( + <> + dispatch(action) : undefined} + /> + {branch ? ( + publishCompletion.mutateAsync()} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts new file mode 100644 index 0000000000..e19b310496 --- /dev/null +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -0,0 +1,621 @@ +import { describe, expect, test } from "bun:test"; +import type { BranchMeta } from "@decocms/sandbox/shared"; +import { + selectCmsHeaderButton, + type SelectCmsHeaderButtonInput, +} from "./cms-panel-state"; +import type { CheckRun, PrSummary } from "./use-pr-data"; +import type { PrReviewSignals } from "./use-pr-reviews"; +import type { TFunction, TranslationKey } from "@/i18n/use-t.ts"; +import type { InterpolationVars } from "@/i18n/interpolate.ts"; +import { thread as threadEn } from "@/i18n/en/thread.ts"; + +const mockT: TFunction = (key: TranslationKey, vars?: InterpolationVars) => { + const template = + (threadEn as Record)[key as string] ?? (key as string); + if (!vars) return template; + return Object.entries(vars).reduce( + (s, [k, v]) => s.replace(`{${k}}`, String(v)), + template, + ); +}; + +type ReadyBranch = Extract; + +/** + * Fast Preview has no working tree: `workingTreeDirty` and `unpushed` are + * pinned to their empty values here because the daemon never reports anything + * else in this mode. + */ +function ready(over: Partial = {}): ReadyBranch { + return { + kind: "ready", + branch: "content/x", + base: "main", + workingTreeDirty: false, + unpushed: 0, + aheadOfBase: 0, + behindBase: 0, + headSha: "abc123", + ...over, + }; +} + +function input( + over: Partial = {}, +): SelectCmsHeaderButtonInput { + return { + branch: ready(), + pr: null, + checks: [], + reviews: null, + publishing: false, + loading: false, + t: mockT, + ...over, + }; +} + +function pr(over: Partial = {}): PrSummary { + return { + number: 42, + title: "Update homepage copy", + body: "", + state: "open", + merged: false, + mergedAt: null, + base: "main", + head: "content/x", + headSha: "abc123", + headRepoFullName: "acme/web", + htmlUrl: "https://github.com/acme/web/pull/42", + author: "me", + ...over, + }; +} + +function check(over: Partial = {}): CheckRun { + return { + id: "1", + name: "lint", + status: "completed", + conclusion: "success", + htmlUrl: "", + durationMs: null, + ...over, + }; +} + +function reviews(over: Partial = {}): PrReviewSignals { + return { + draft: false, + mergeableState: "clean", + unresolvedConversations: 0, + missingRequiredApprovals: false, + ...over, + }; +} + +const running = check({ + id: "2", + name: "build", + status: "in_progress", + conclusion: null, +}); +const failed = check({ id: "3", name: "unit", conclusion: "failure" }); + +function menuKeys(menu: { key: string }[]): string[] { + return menu.map((m) => m.key); +} + +describe("selectCmsHeaderButton — 1. loading", () => { + test("loading flag → Loading… (disabled, spinner, no menu)", () => { + const r = selectCmsHeaderButton(input({ loading: true })); + expect(r.label).toBe("Loading…"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("branch not ready → Loading…", () => { + const r = selectCmsHeaderButton(input({ branch: { kind: "unknown" } })); + expect(r.label).toBe("Loading…"); + expect(r.loading).toBe(true); + }); + + test("loading beats publishing", () => { + const r = selectCmsHeaderButton(input({ loading: true, publishing: true })); + expect(r.label).toBe("Loading…"); + }); + + test("branch not ready beats an open PR ready to publish", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.label).toBe("Loading…"); + }); +}); + +describe("selectCmsHeaderButton — 2. publishing", () => { + test("publishing → Publishing… (disabled, spinner, no menu)", () => { + const r = selectCmsHeaderButton(input({ publishing: true })); + expect(r.label).toBe("Publishing…"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("publishing beats every PR state, including conflicts", () => { + const r = selectCmsHeaderButton( + input({ + publishing: true, + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(r.label).toBe("Publishing…"); + expect(r.menu).toEqual([]); + }); +}); + +describe("selectCmsHeaderButton — 3. needs attention (conflicts)", () => { + test("open PR + dirty → Get latest with Resolve on GitHub menu", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(r.label).toBe("Get latest"); + expect(r.action).toBe("get-latest"); + expect(r.variant).toBe("default"); + expect(r.tooltip).toBe("Bring in new changes from production"); + expect(r.menu).toEqual([ + { + key: "resolve-on-github", + label: "Resolve on GitHub", + action: "open-pr", + }, + ]); + }); + + test("conflicts beat draft and beat failing checks", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [failed], + reviews: reviews({ mergeableState: "dirty", draft: true }), + }), + ); + expect(r.label).toBe("Get latest"); + expect(r.variant).toBe("default"); + expect(r.tooltip).toBe("Bring in new changes from production"); + }); + + test("conflicts + behind base → no duplicate Get latest in the menu", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 5 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(menuKeys(r.menu)).toEqual(["resolve-on-github"]); + }); +}); + +describe("selectCmsHeaderButton — 4. waiting for approval", () => { + test("open PR + blocked → Waiting for approval (opens the PR)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.label).toBe("Waiting for approval"); + expect(r.action).toBe("open-pr"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBeFalsy(); + expect(r.menu).toEqual([ + { key: "view-on-github", label: "View on GitHub", action: "open-pr" }, + ]); + }); + + test("open PR + draft (clean mergeable state) → Waiting for approval", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ draft: true }), + }), + ); + expect(r.label).toBe("Waiting for approval"); + expect(r.action).toBe("open-pr"); + }); + + test("no checks → outline, no spinner, no pulse, no tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("outline"); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("all checks passed → outline, no spinner, no pulse", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), check({ id: "9", name: "types" })], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("outline"); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("check running → spinner (not pulse) + progress tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), running], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.loading).toBe(true); + expect(r.pulse).toBeFalsy(); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + expect(r.variant).toBe("outline"); + }); + + test("check failed, none running → warning + failing tooltip, not disabled", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("warning"); + expect(r.tooltip).toBe("1 of 2 checks are not passing"); + expect(r.disabled).toBeFalsy(); + expect(r.action).toBe("open-pr"); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + }); + + test("mixed failed + running → running wins (spinner, outline)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed, running], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.loading).toBe(true); + expect(r.pulse).toBeFalsy(); + expect(r.variant).toBe("outline"); + expect(r.tooltip).toBe("Running checks 2 of 3 done"); + }); +}); + +describe("selectCmsHeaderButton — 5. ready to publish", () => { + test("open PR + clean → Review & Publish (brand)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + expect(r.variant).toBe("brand"); + expect(r.menu).toEqual([ + { key: "view-on-github", label: "View on GitHub", action: "open-pr" }, + ]); + }); + + test("reviews still loading (null → unknown) → Review & Publish", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), pr: pr(), reviews: null }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + }); + + test.each(["unstable", "behind", "unknown"] as const)( + "mergeableState=%s → Review & Publish", + (mergeableState) => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.variant).toBe("brand"); + }, + ); + + test("no checks → brand, no spinner, no pulse, no tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.variant).toBe("brand"); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("all checks passed → brand, no spinner, no pulse", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), check({ id: "9", name: "types" })], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("brand"); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("check running → pulse (not spinner) + progress tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), running], + reviews: reviews(), + }), + ); + expect(r.pulse).toBe(true); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + expect(r.variant).toBe("brand"); + expect(r.action).toBe("publish"); + }); + + test("check failed, none running → warning overrides brand, still publishable", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed, check({ id: "4", conclusion: "timed_out" })], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("warning"); + expect(r.tooltip).toBe("2 of 3 checks are not passing"); + expect(r.action).toBe("publish"); + expect(r.disabled).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + }); + + test("mixed failed + running → running wins (pulse, brand)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [failed, running], + reviews: reviews(), + }), + ); + expect(r.pulse).toBe(true); + expect(r.loading).toBeFalsy(); + expect(r.variant).toBe("brand"); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + }); +}); + +describe("selectCmsHeaderButton — 6. draft (no open PR)", () => { + test("ahead of base, no PR → Review & Publish + Request approval", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 3 }) }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + expect(r.variant).toBe("brand"); + expect(r.menu).toEqual([ + { + key: "request-approval", + label: "Request approval", + action: "request-approval", + }, + ]); + }); + + test("ahead of a merged PR the branch has moved past → Draft", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3, headSha: "after-merge" }), + pr: pr({ + state: "closed", + merged: true, + mergedAt: "2026-04-22", + headSha: "at-merge", + }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(menuKeys(r.menu)).toEqual(["request-approval"]); + }); + + test("ahead of base + closed PR → Draft", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3 }), + pr: pr({ state: "closed", merged: false }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + }); + + test("checks are ignored without an open PR", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 3 }), checks: [failed, running] }), + ); + expect(r.variant).toBe("brand"); + expect(r.tooltip).toBeUndefined(); + expect(r.loading).toBeFalsy(); + expect(r.pulse).toBeFalsy(); + }); +}); + +describe("selectCmsHeaderButton — 7. up to date", () => { + test("nothing ahead, no PR → Up to date (disabled, empty menu)", () => { + const r = selectCmsHeaderButton(input()); + expect(r.label).toBe("Up to date"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("disabled primary still carries a Get latest menu when behind", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 4 }) }), + ); + expect(r.label).toBe("Up to date"); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([ + { + key: "get-latest", + label: "Get latest", + action: "get-latest", + tooltip: "Bring in new changes from production", + }, + ]); + }); +}); + +describe("selectCmsHeaderButton — Get latest in every menu when behind", () => { + test("state 4 (waiting for approval) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 1 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); + + test("state 5 (ready to publish) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 1 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); + + test("state 6 (draft) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2, behindBase: 1 }) }), + ); + expect(menuKeys(r.menu)).toEqual(["request-approval", "get-latest"]); + }); + + test("state 7 (up to date) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 1 }) }), + ); + expect(menuKeys(r.menu)).toEqual(["get-latest"]); + }); + + test("behindBase = 0 → no Get latest anywhere", () => { + for (const r of [ + selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ), + selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ), + selectCmsHeaderButton(input({ branch: ready({ aheadOfBase: 2 }) })), + selectCmsHeaderButton(input()), + ]) { + expect(menuKeys(r.menu)).not.toContain("get-latest"); + } + }); + + test("Get latest is appended alongside the check treatment", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + pr: pr(), + checks: [failed], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("warning"); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); +}); + +describe("merged pull request", () => { + test("stays 'Up to date' when the branch is level with a merged PR", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, headSha: "merged-sha" }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + }); + + test("offers to publish again once the branch advances past the merge", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3, headSha: "new-sha" }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + }); +}); diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts new file mode 100644 index 0000000000..75a0f6c6e1 --- /dev/null +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -0,0 +1,298 @@ +/** + * Header button state machine for **Fast Preview** (CMS) mode. + * + * Fast Preview is the sandbox-less, content-only editing surface: there is no + * working tree (`workingTreeDirty` is always false and `unpushed` always 0 — + * edits are committed as they are made), no coding agent, and no chat. That + * removes every "commit / push / fix tests / address feedback" branch of + * {@link ./panel-state.ts selectHeaderButton} and leaves seven states driven + * only by `aheadOfBase`, `behindBase`, the pull request, and its check runs. + * + * The vocabulary is the editor's, not git's: "Review & Publish" instead of + * "Submit for review", "Get latest" instead of "Sync with main". + */ + +import type { BranchMeta } from "@decocms/sandbox/shared"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { isCheckFailed, isCheckInProgress } from "./panel-state.ts"; +import type { CheckRun, PrSummary } from "./use-pr-data.ts"; +import type { PrReviewSignals } from "./use-pr-reviews.ts"; + +/** + * What a click resolves to. The renderer maps these to mutations; the state + * machine never performs them. + * + * - `publish` — merge the PR (opening one first when there isn't one yet). + * - `request-approval` — open the PR so a reviewer can approve it. + * - `get-latest` — bring `base` into the working branch. + * - `open-pr` — open the PR on GitHub in a new tab. + */ +export type CmsAction = + | "publish" + | "request-approval" + | "get-latest" + | "open-pr"; + +/** One entry of the split button's dropdown half. `key` is the React key. */ +export interface CmsMenuItem { + key: string; + label: string; + action: CmsAction; + tooltip?: string; +} + +/** + * Descriptor returned by {@link selectCmsHeaderButton}. + * + * An absent `action` means the primary half is an inert status pill. `loading` + * puts a spinner in the primary half and reads as "wait"; `pulse` animates the + * whole control and reads as "something is happening, but you may still act" — + * the two are deliberately never both set. `disabled` and a non-empty `menu` + * can coexist: "Up to date" has nothing to publish yet still offers + * "Get latest" when the branch is behind. + */ +export interface CmsHeaderButton { + label: string; + /** Absent = inert status pill. */ + action?: CmsAction; + variant: "brand" | "warning" | "outline" | "default"; + disabled?: boolean; + /** Spinner in the primary half. */ + loading?: boolean; + /** Whole control pulses. */ + pulse?: boolean; + tooltip?: string; + menu: CmsMenuItem[]; +} + +export interface SelectCmsHeaderButtonInput { + branch: BranchMeta; + pr: PrSummary | null; + checks: CheckRun[]; + reviews: PrReviewSignals | null; + /** A publish is in flight — optimistic, set at click time. */ + publishing: boolean; + /** Branch/PR/check data is still being fetched. */ + loading: boolean; + t: TFunction; +} + +function viewOnGithubItem(t: TFunction): CmsMenuItem { + return { + key: "view-on-github", + label: t("thread.cmsActions.viewOnGithub"), + action: "open-pr", + }; +} + +/** + * Appends "Get latest" to `menu` when the branch has commits on `base` it + * hasn't taken in yet. Applied to every state whose primary action isn't + * already `get-latest`, including the disabled "Up to date" pill. + */ +function withGetLatest( + menu: CmsMenuItem[], + branch: BranchMeta, + t: TFunction, +): CmsMenuItem[] { + if (branch.kind !== "ready" || branch.behindBase <= 0) return menu; + return [ + ...menu, + { + key: "get-latest", + label: t("thread.cmsActions.getLatest"), + action: "get-latest", + tooltip: t("thread.cmsActions.getLatestTooltip"), + }, + ]; +} + +/** + * Folds check-run status into a button descriptor. Only meaningful for the two + * open-PR states — checks exist only once a PR does. + * + * Running beats failing: while anything is still queued the failure set isn't + * final. A failure never disables the button; publishing with red checks is a + * judgement call the editor is allowed to make, so it only recolours to + * `warning` and explains itself in the tooltip. + * + * `runningTreatment` splits the two states: "Waiting for approval" is a + * genuine wait, so it takes a spinner; "Ready to publish" stays actionable, so + * it pulses instead — a spinner there would wrongly imply the editor should + * hold off. + */ +function applyCheckTreatment( + button: CmsHeaderButton, + checks: CheckRun[], + runningTreatment: "loading" | "pulse", + t: TFunction, +): CmsHeaderButton { + const total = checks.length; + if (total === 0) return button; + + const running = checks.filter(isCheckInProgress).length; + if (running > 0) { + const done = checks.filter((c) => c.status === "completed").length; + const tooltip = t("thread.cmsActions.checksRunning", { done, total }); + return runningTreatment === "loading" + ? { ...button, loading: true, tooltip } + : { ...button, pulse: true, tooltip }; + } + + const failed = checks.filter(isCheckFailed).length; + if (failed > 0) { + return { + ...button, + variant: "warning", + tooltip: t("thread.cmsActions.checksFailing", { failed, total }), + }; + } + + return button; +} + +/** + * Whether the branch sits exactly on a PR that has already been merged. + * + * A squash-merge leaves the branch's own commits on origin with their original + * SHAs, so `aheadOfBase` stays positive after a successful publish. Publishing + * moves the editor to a fresh branch, but when that hasn't happened the branch + * is level with its merged PR — and offering to publish again would open a + * second pull request for content that is already live. + */ +function isLevelWithMergedPr( + pr: PrSummary | null, + branch: BranchMeta, +): boolean { + return Boolean( + pr?.merged && + pr.headSha && + branch.kind === "ready" && + branch.headSha === pr.headSha, + ); +} + +/** + * Picks the Fast Preview header button. First match wins, in the order below: + * + * 1. Loading — data in flight, or branch metadata not yet known. + * 2. Publishing — a publish is in flight. + * 3. Needs attention — open PR conflicting with base. + * 4. Waiting for approval — open PR blocked on review, or still a draft. + * 5. Ready to publish — open PR, every other mergeable state. + * 6. Draft — committed edits with no PR to carry them. + * 7. Up to date — nothing to publish. + */ +export function selectCmsHeaderButton( + input: SelectCmsHeaderButtonInput, +): CmsHeaderButton { + const { branch, pr, checks, reviews, publishing, loading, t } = input; + + // Fetching and "branch metadata not here yet" are one state to the editor. + if (loading || branch.kind !== "ready") { + return { + label: t("thread.headerActions.loading"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + if (publishing) { + return { + label: t("thread.cmsActions.publishing"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + const openPr = pr && pr.state === "open" && !pr.merged ? pr : null; + + if (openPr) { + const mergeableState = reviews?.mergeableState ?? "unknown"; + + // Conflicts outrank CI, and "Get latest" is already the primary here. + if (mergeableState === "dirty") { + return { + label: t("thread.cmsActions.getLatest"), + action: "get-latest", + variant: "default", + tooltip: t("thread.cmsActions.getLatestTooltip"), + menu: [ + { + key: "resolve-on-github", + label: t("thread.cmsActions.resolveOnGithub"), + action: "open-pr", + }, + ], + }; + } + + // Blocked on a human; the primary opens the PR, the only place they act. + if (mergeableState === "blocked" || reviews?.draft) { + return applyCheckTreatment( + { + label: t("thread.cmsActions.waitingForApproval"), + action: "open-pr", + variant: "outline", + menu: withGetLatest([viewOnGithubItem(t)], branch, t), + }, + checks, + "loading", + t, + ); + } + + // clean / unstable / behind / unknown — all publishable. + return applyCheckTreatment( + { + label: t("thread.cmsActions.reviewAndPublish"), + action: "publish", + variant: "brand", + menu: withGetLatest([viewOnGithubItem(t)], branch, t), + }, + checks, + "pulse", + t, + ); + } + + if (isLevelWithMergedPr(pr, branch)) { + return { + label: t("thread.headerActions.upToDate"), + variant: "outline", + disabled: true, + menu: withGetLatest([], branch, t), + }; + } + + if (branch.aheadOfBase > 0) { + return { + label: t("thread.cmsActions.reviewAndPublish"), + action: "publish", + variant: "brand", + menu: withGetLatest( + [ + { + key: "request-approval", + label: t("thread.cmsActions.requestApproval"), + action: "request-approval", + }, + ], + branch, + t, + ), + }; + } + + // Disabled primary, live menu: nothing to publish but base may have moved. + return { + label: t("thread.headerActions.upToDate"), + variant: "outline", + disabled: true, + menu: withGetLatest([], branch, t), + }; +} diff --git a/apps/web/src/components/thread/github/panel-state.ts b/apps/web/src/components/thread/github/panel-state.ts index 93f69f41d3..24ad56b09a 100644 --- a/apps/web/src/components/thread/github/panel-state.ts +++ b/apps/web/src/components/thread/github/panel-state.ts @@ -72,14 +72,16 @@ const FAILED_CONCLUSIONS = new Set([ "action_required", ]); -function isCheckFailed(c: CheckRun): boolean { +/** Shared with the Fast Preview state machine (`cms-panel-state.ts`). */ +export function isCheckFailed(c: CheckRun): boolean { return ( c.status === "completed" && FAILED_CONCLUSIONS.has(c.conclusion as FailedConclusion) ); } -function isCheckInProgress(c: CheckRun): boolean { +/** Shared with the Fast Preview state machine (`cms-panel-state.ts`). */ +export function isCheckInProgress(c: CheckRun): boolean { return c.status === "queued" || c.status === "in_progress"; } diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 63d95e913a..23458ee530 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -43,6 +43,18 @@ export const thread = { "thread.checksTab.rerun": "Re-run", "thread.checksTab.success": "Success", "thread.checksTab.viewRun": "View run", + "thread.cmsActions.checksFailing": + "{failed} of {total} checks are not passing", + "thread.cmsActions.checksRunning": "Running checks {done} of {total} done", + "thread.cmsActions.getLatest": "Get latest", + "thread.cmsActions.getLatestTooltip": "Bring in new changes from production", + "thread.cmsActions.moreActionsAriaLabel": "More actions", + "thread.cmsActions.publishing": "Publishing…", + "thread.cmsActions.requestApproval": "Request approval", + "thread.cmsActions.resolveOnGithub": "Resolve on GitHub", + "thread.cmsActions.reviewAndPublish": "Review & Publish", + "thread.cmsActions.viewOnGithub": "View on GitHub", + "thread.cmsActions.waitingForApproval": "Waiting for approval", "thread.gitTab.by": "by @{author}", "thread.gitTab.closed": "✗ Closed", "thread.gitTab.couldNotLoadPrState": diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index fd80592c51..647388b1f9 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -46,6 +46,19 @@ export const thread = { "thread.checksTab.rerun": "Executar novamente", "thread.checksTab.success": "Sucesso", "thread.checksTab.viewRun": "Ver execução", + "thread.cmsActions.checksFailing": + "{failed} de {total} verificações não estão passando", + "thread.cmsActions.checksRunning": "Verificando {done} de {total} concluídas", + "thread.cmsActions.getLatest": "Obter atualizações", + "thread.cmsActions.getLatestTooltip": + "Trazer as novas alterações da produção", + "thread.cmsActions.moreActionsAriaLabel": "Mais ações", + "thread.cmsActions.publishing": "Publicando…", + "thread.cmsActions.requestApproval": "Pedir aprovação", + "thread.cmsActions.resolveOnGithub": "Resolver no GitHub", + "thread.cmsActions.reviewAndPublish": "Revisar e publicar", + "thread.cmsActions.viewOnGithub": "Ver no GitHub", + "thread.cmsActions.waitingForApproval": "Aguardando aprovação", "thread.gitTab.by": "por @{author}", "thread.gitTab.closed": "✗ Fechado", "thread.gitTab.couldNotLoadPrState": diff --git a/apps/web/src/views/virtual-mcp/header-info.tsx b/apps/web/src/views/virtual-mcp/header-info.tsx index b8497f8dab..d29f68c94b 100644 --- a/apps/web/src/views/virtual-mcp/header-info.tsx +++ b/apps/web/src/views/virtual-mcp/header-info.tsx @@ -1,5 +1,7 @@ import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { agentShowsGithubHeaderActions } from "@/lib/agent-capabilities"; +import { resolveFastPreview } from "@/sdk/fast-preview"; +import { CmsHeaderActions } from "../../components/thread/github/cms-header-actions.tsx"; import { HeaderActions } from "../../components/thread/github/header-actions.tsx"; import { DevAgentControl } from "../../components/dev-agent/dev-agent-control.tsx"; import { OpenInBoardButton } from "../../components/thread/open-in-board-button.tsx"; @@ -7,18 +9,28 @@ import { OpenInBoardButton } from "../../components/thread/open-in-board-button. /** * The agent's header actions (dev-agent control + GitHub publish/PR buttons), * rendered inline into the main panel header's right cluster. + * + * Fast Preview swaps in the CMS renderer here, not inside `HeaderActions`, so + * the sandbox hooks that renderer mounts (events, lifecycle, publish gate) + * never mount on a surface that has no sandbox. */ export function VirtualMcpHeaderInfo({ virtualMcp, }: { virtualMcp: VirtualMCPEntity; }) { + const fastPreviewActive = resolveFastPreview(virtualMcp.metadata).active; + return (
{agentShowsGithubHeaderActions(virtualMcp) ? ( - + fastPreviewActive ? ( + + ) : ( + + ) ) : null}
); diff --git a/packages/ui/src/components/button.stories.tsx b/packages/ui/src/components/button.stories.tsx index 0b64816f77..d612423237 100644 --- a/packages/ui/src/components/button.stories.tsx +++ b/packages/ui/src/components/button.stories.tsx @@ -20,6 +20,7 @@ const meta = { "ghost", "destructive", "success", + "warning", "special", "link", ], @@ -45,6 +46,7 @@ export const Variants: Story = { + diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index b687e3c428..d5a11e5694 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -18,6 +18,8 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground hover:bg-secondary/80", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/20 dark:focus-visible:ring-success/40", + warning: + "bg-warning text-warning-foreground hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40", brand: "bg-brand text-brand-foreground hover:bg-brand/90 focus-visible:ring-brand/20 dark:focus-visible:ring-brand/40", special: diff --git a/packages/ui/src/components/split-button.stories.tsx b/packages/ui/src/components/split-button.stories.tsx new file mode 100644 index 0000000000..28fabd43d7 --- /dev/null +++ b/packages/ui/src/components/split-button.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GitBranch01 } from "@untitledui/icons"; +import { SplitButton, type SplitButtonMenuItem } from "./split-button.tsx"; + +const items: SplitButtonMenuItem[] = [ + { key: "review", label: "Request review", onSelect: () => {} }, + { key: "draft", label: "Convert to draft", onSelect: () => {} }, + { + key: "force", + label: "Force publish", + disabled: true, + tooltip: "Only maintainers can force publish", + onSelect: () => {}, + }, +]; + +const meta = { + title: "Components/SplitButton", + component: SplitButton, + args: { + label: "Publish to main", + menuAriaLabel: "More publish options", + variant: "default", + size: "default", + items, + onClick: () => {}, + }, + argTypes: { + variant: { + control: "select", + options: [ + "default", + "secondary", + "outline", + "destructive", + "success", + "warning", + "brand", + "special", + ], + }, + size: { + control: "select", + options: ["xs", "sm", "default", "lg", "xl"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No `items` — the chevron half is not rendered, leaving a plain button. */ +export const WithoutMenu: Story = { + args: { items: [] }, +}; + +/** The primary action is unavailable, but the menu still offers alternatives. */ +export const DisabledPrimaryWithMenu: Story = { + args: { + label: "Up to date", + disabled: true, + tooltip: "There is nothing new to publish", + }, +}; + +export const Loading: Story = { + args: { label: "Publishing", loading: true }, +}; + +/** Brightness breathes; under `prefers-reduced-motion` it dims statically. */ +export const Pulse: Story = { + args: { pulse: true, variant: "brand" }, +}; + +export const WithIcon: Story = { + args: { icon: }, +}; + +export const Variants: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; + +export const Sizes: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; diff --git a/packages/ui/src/components/split-button.tsx b/packages/ui/src/components/split-button.tsx new file mode 100644 index 0000000000..425c65bf86 --- /dev/null +++ b/packages/ui/src/components/split-button.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type * as React from "react"; +import { ChevronDown } from "@untitledui/icons"; + +import { cn } from "../lib/utils.ts"; +import { Button } from "./button.tsx"; +import { ButtonGroup } from "./button-group.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown-menu.tsx"; +import { Spinner } from "./spinner.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.tsx"; + +type ButtonProps = React.ComponentProps; + +export interface SplitButtonMenuItem { + /** Stable identity for the item; also its React key. */ + key: string; + label: string; + onSelect: () => void; + disabled?: boolean; + tooltip?: string; +} + +export interface SplitButtonProps { + label: string; + onClick?: () => void; + variant?: ButtonProps["variant"]; + size?: ButtonProps["size"]; + /** + * Disables the primary half ONLY. The menu half stays operable so a control + * whose main action is unavailable ("Up to date") can still offer actions. + */ + disabled?: boolean; + /** Shows a spinner in the primary half and swallows its clicks. */ + loading?: boolean; + /** Breathing brightness on the whole control; static dimming without motion. */ + pulse?: boolean; + /** Tooltip on the primary half — shown even while it is disabled. */ + tooltip?: string; + icon?: React.ReactNode; + /** With no items the chevron half is not rendered at all. */ + items?: SplitButtonMenuItem[]; + /** Accessible name for the chevron trigger. Required: this package is i18n-free. */ + menuAriaLabel: string; + className?: string; +} + +function SplitButtonMenuEntry({ item }: { item: SplitButtonMenuItem }) { + const entry = ( + { + item.onSelect(); + }} + > + {item.label} + + ); + + if (!item.tooltip) { + return entry; + } + + return ( + + {/* Wrapper is the trigger: a disabled item is pointer-events-none. */} + + {entry} + + {item.tooltip} + + ); +} + +/** + * A primary action with an attached dropdown half — `[ Primary | v ]`. + * + * Without `items` it collapses to a plain single button (same rounding as + * `Button`); with them, the halves share one control and only the primary + * responds to `disabled`. + */ +export function SplitButton({ + label, + onClick, + variant = "default", + size = "default", + disabled = false, + loading = false, + pulse = false, + tooltip, + icon, + items, + menuAriaLabel, + className, +}: SplitButtonProps) { + const hasMenu = (items?.length ?? 0) > 0; + + const primary = ( + + ); + + return ( + + {tooltip ? ( + + {/* Wrapper is the trigger: a disabled button swallows hover and focus. */} + + + {primary} + + + {tooltip} + + ) : ( + primary + )} + + {hasMenu ? ( + + + + + + {items?.map((item) => ( + + ))} + + + ) : null} + + ); +} diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index ce954ae29b..3f91bcb40b 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -281,6 +281,8 @@ infinite; --animate-preview-ramp: preview-ramp 20s var(--ease-out-cubic) forwards; --animate-card-land: card-land 240ms var(--ease-out-quint) both; + --animate-pulse-brightness: pulse-brightness 1.8s var(--ease-in-out-cubic) + infinite; } /* Animations */ @@ -308,6 +310,20 @@ --ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86); } +/* Draws the eye to a control that is waiting on the user without moving it — + only its own brightness breathes. Callers pair it with + `motion-reduce:animate-none` plus a static dimmed treatment so the state + survives when motion is off. */ +@keyframes pulse-brightness { + 0%, + 100% { + filter: brightness(1); + } + 50% { + filter: brightness(1.15); + } +} + /* A kanban card settling into the lane it was just dropped in. With live gap preview the card is already sitting in its destination when the pointer is released, so there's no distance left to travel — the motion has to be the From de327a21bc8b0a8294e160b0ca00ff61b66cceeb Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 15:49:32 -0300 Subject: [PATCH 2/8] fix(fast-preview): react to uncommitted edits, in-flight saves and generated-only churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found driving the real CMS in the native app. 1. The Draft state only looked at `aheadOfBase`, and the BranchMeta mapping pinned `workingTreeDirty: false`. That held for the GitHub-backed `/git/status` shim, which commits on every save — but the same contract is served by a real clone whenever the project isn't claimed as Fast Preview server-side, and there the editor's saves sit uncommitted. The button claimed "Up to date" over unpublished edits. Unpublished work is now `aheadOfBase > 0 || workingTreeDirty`, and `isLevelWithMergedPr` yields to a dirty tree so post-merge edits aren't swallowed either. 2. No saving state. A publish fired mid-write would ship whichever half of the edit had landed. Reads the same `decofileWriteMutationKey` the preview's autosave indicator uses, so edits and deletes are both covered. The menu is empty while saving: a branch-wins merge racing an in-flight write is how an edit goes missing. 3. `.deco/generate.digests.json` and `.deco/meta.gen.json` are rewritten on every save and never reverted by an undo, so a dirty-tree check counted them as work the publish diff couldn't find — "0 changes to publish" under a live button. `hasPublishableLocalWork` ignores generated artifacts. Known gap, deliberately left: on the commit-per-save backend, edit-then-undo leaves N commits with a net-zero diff, and `aheadOfBase > 0` still reads as Draft. Closing it needs a changed-file count from the `compare` call `githubGitStatus` already makes — an apps/api change, tracked separately. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 33 ++++++---- .../thread/github/cms-panel-state.test.ts | 63 +++++++++++++++++++ .../thread/github/cms-panel-state.ts | 51 ++++++++------- .../thread/github/sandbox-git-api.test.ts | 61 ++++++++++++++++++ .../thread/github/sandbox-git-api.ts | 25 ++++++++ 5 files changed, 196 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index 1210e7e998..eefedbc7d8 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -28,7 +28,12 @@ import { TooltipProvider, TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useIsMutating, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; import { useT } from "@/i18n/use-t"; @@ -37,12 +42,14 @@ import { resolveGithubAttachment } from "@/lib/github-repo.ts"; import { KEYS } from "@/lib/query-keys"; import { useProjectContext, useVirtualMCP } from "@/sdk"; import { resolveFastPreview } from "@/sdk/fast-preview"; +import { decofileWriteMutationKey } from "../../sections-editor/decofile-api.ts"; import { useChatTask } from "../../chat/index"; import { selectCmsHeaderButton, type CmsAction } from "./cms-panel-state.ts"; import { isPrStateActivelyLoading } from "./panel-state.ts"; import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; import { fetchGitStatus, + hasPublishableLocalWork, normalizePublishPolicy, readGitHeadBranch, rebaseGitBranch, @@ -73,14 +80,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { : null; const { previewServerUrl } = resolveFastPreview(vm?.metadata); - /** - * Sandbox-less: no daemon → no `branch` SSE event. Fetch the (GitHub-backed) - * status route into the same BranchMeta shape — but never on an interval: - * every call forwards to the GitHub API, and a timer here burns rate limit - * for data that only changes when WE commit. The only in-app mutation is the - * decofile PATCH, whose save hooks invalidate this key; external pushes are - * picked up on window focus. - */ + /** Poll-free on purpose: every call forwards to GitHub; save hooks invalidate this key. */ const statusQuery = useQuery({ queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, branch ?? ""), queryFn: () => fetchGitStatus(org.slug, virtualMcpId, branch ?? ""), @@ -93,8 +93,8 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { kind: "ready", branch: readGitHeadBranch(status) ?? branch ?? "", base: status.base ?? "main", - workingTreeDirty: false, - unpushed: 0, + workingTreeDirty: hasPublishableLocalWork(status), + unpushed: status.unpushed ?? 0, aheadOfBase: status.aheadOfBase ?? 0, behindBase: status.behindBase ?? 0, headSha: status.headSha ?? "", @@ -195,6 +195,16 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { }, }); + /** Same in-flight signal the preview's autosave indicator reads. */ + const saving = + useIsMutating({ + mutationKey: decofileWriteMutationKey( + org.slug, + virtualMcpId, + branch ?? "", + ), + }) > 0; + /** * Detached: repo linked via a GitHub connection that's no longer aggregated. * Render a reconnect pill instead of nothing so the user has a recovery path. @@ -237,6 +247,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { checks: checksQuery.data ?? [], reviews: reviewsQuery.data ?? null, publishing, + saving, loading: isPrStateActivelyLoading(prQuery), t, }); diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index e19b310496..c3001921ab 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -50,6 +50,7 @@ function input( checks: [], reviews: null, publishing: false, + saving: false, loading: false, t: mockT, ...over, @@ -595,6 +596,68 @@ describe("selectCmsHeaderButton — Get latest in every menu when behind", () => }); }); +describe("saving", () => { + test("an in-flight block write holds the button", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), saving: true }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.saving"]); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("Get latest is withheld while saving, even when behind", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 4 }), saving: true }), + ); + expect(r.menu).toEqual([]); + }); + + test("publishing outranks saving", () => { + const r = selectCmsHeaderButton(input({ publishing: true, saving: true })); + expect(r.label).toBe(threadEn["thread.cmsActions.publishing"]); + }); + + test("saving is transparent once the write settles", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), saving: false }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); +}); + +describe("uncommitted work", () => { + test("a dirty tree with nothing committed is still Draft", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 0, workingTreeDirty: true }) }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + expect(r.disabled).toBeFalsy(); + }); + + test("a clean branch level with base is Up to date", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 0, workingTreeDirty: false }) }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + expect(r.disabled).toBe(true); + }); + + test("a dirty tree beats a merged PR at the same head", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ headSha: "merged-sha", workingTreeDirty: true }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + }); +}); + describe("merged pull request", () => { test("stays 'Up to date' when the branch is level with a merged PR", () => { const r = selectCmsHeaderButton( diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index 75a0f6c6e1..f1548fc0d0 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -1,16 +1,4 @@ -/** - * Header button state machine for **Fast Preview** (CMS) mode. - * - * Fast Preview is the sandbox-less, content-only editing surface: there is no - * working tree (`workingTreeDirty` is always false and `unpushed` always 0 — - * edits are committed as they are made), no coding agent, and no chat. That - * removes every "commit / push / fix tests / address feedback" branch of - * {@link ./panel-state.ts selectHeaderButton} and leaves seven states driven - * only by `aheadOfBase`, `behindBase`, the pull request, and its check runs. - * - * The vocabulary is the editor's, not git's: "Review & Publish" instead of - * "Submit for review", "Get latest" instead of "Sync with main". - */ +/** Fast Preview (CMS) header button state machine; see ./panel-state.ts for vibecoding's. */ import type { BranchMeta } from "@decocms/sandbox/shared"; import type { TFunction } from "@/i18n/use-t.ts"; @@ -72,6 +60,8 @@ export interface SelectCmsHeaderButtonInput { reviews: PrReviewSignals | null; /** A publish is in flight — optimistic, set at click time. */ publishing: boolean; + /** A block write is in flight; the branch state is mid-change. */ + saving: boolean; /** Branch/PR/check data is still being fetched. */ loading: boolean; t: TFunction; @@ -168,25 +158,23 @@ function isLevelWithMergedPr( pr?.merged && pr.headSha && branch.kind === "ready" && + !branch.workingTreeDirty && branch.headSha === pr.headSha, ); } -/** - * Picks the Fast Preview header button. First match wins, in the order below: - * - * 1. Loading — data in flight, or branch metadata not yet known. - * 2. Publishing — a publish is in flight. - * 3. Needs attention — open PR conflicting with base. - * 4. Waiting for approval — open PR blocked on review, or still a draft. - * 5. Ready to publish — open PR, every other mergeable state. - * 6. Draft — committed edits with no PR to carry them. - * 7. Up to date — nothing to publish. - */ +/** Not live yet: one `/git/status` backend commits each save, the other doesn't. */ +function hasUnpublishedWork( + branch: Extract, +): boolean { + return branch.aheadOfBase > 0 || branch.workingTreeDirty; +} + +/** Picks the Fast Preview header button; first match wins, so order is behavior. */ export function selectCmsHeaderButton( input: SelectCmsHeaderButtonInput, ): CmsHeaderButton { - const { branch, pr, checks, reviews, publishing, loading, t } = input; + const { branch, pr, checks, reviews, publishing, saving, loading, t } = input; // Fetching and "branch metadata not here yet" are one state to the editor. if (loading || branch.kind !== "ready") { @@ -209,6 +197,17 @@ export function selectCmsHeaderButton( }; } + /** Publishing mid-write would ship whichever half of the edit landed. */ + if (saving) { + return { + label: t("thread.headerActions.saving"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + const openPr = pr && pr.state === "open" && !pr.merged ? pr : null; if (openPr) { @@ -269,7 +268,7 @@ export function selectCmsHeaderButton( }; } - if (branch.aheadOfBase > 0) { + if (hasUnpublishedWork(branch)) { return { label: t("thread.cmsActions.reviewAndPublish"), action: "publish", diff --git a/apps/web/src/components/thread/github/sandbox-git-api.test.ts b/apps/web/src/components/thread/github/sandbox-git-api.test.ts index c95aefb3cb..feffe0e012 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.test.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_PUBLISH_POLICY, hasGitLocalWork, hasLocalWorkToPush, + hasPublishableLocalWork, hasUnpublishedWork, isDecoOnlyDiff, needsSmartReviewJudgment, @@ -238,6 +239,66 @@ describe("hasGitLocalWork", () => { }); }); +describe("hasPublishableLocalWork", () => { + const GENERATED = [ + ".deco/generate.digests.json", + ".deco/meta.gen.json", + "blocks.gen.json", + "static/tailwind.css", + ]; + + test("false for null and a clean tree", () => { + expect(hasPublishableLocalWork(null)).toBe(false); + expect(hasPublishableLocalWork(cleanStatus)).toBe(false); + }); + + test("false when only generated artifacts changed", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + modified: GENERATED, + staged: [".deco/generate.digests.json"], + }), + ).toBe(false); + }); + + test("true when a block changed alongside generated artifacts", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + modified: [...GENERATED, ".deco/blocks/hero.json"], + }), + ).toBe(true); + }); + + test("true for conflicts even with no listed paths", () => { + expect( + hasPublishableLocalWork({ ...cleanStatus, conflicted: ["a.ts"] }), + ).toBe(true); + }); + + test("counts created, deleted and untracked content", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + created: [".deco/blocks/a.json"], + }), + ).toBe(true); + expect( + hasPublishableLocalWork({ + ...cleanStatus, + deleted: [".deco/blocks/a.json"], + }), + ).toBe(true); + expect( + hasPublishableLocalWork({ + ...cleanStatus, + not_added: [".deco/blocks/a.json"], + }), + ).toBe(true); + }); +}); + describe("shouldUseBaseDiff", () => { const opts = { openPrFromCommits: false, commitToOpenPr: false }; diff --git a/apps/web/src/components/thread/github/sandbox-git-api.ts b/apps/web/src/components/thread/github/sandbox-git-api.ts index df6eb9dbd7..7842940cb1 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.ts @@ -225,6 +225,31 @@ function isTailwindCssPath(path: string): boolean { ); } +/** Rewritten as a side effect of any save, and never reverted by an undo. */ +function isGeneratedArtifactPath(path: string): boolean { + return ( + isBlocksGenJsonPath(path) || + isTailwindCssPath(path) || + path.endsWith("generate.digests.json") || + path.endsWith("meta.gen.json") + ); +} + +/** Uncommitted work that would actually change the site. */ +export function hasPublishableLocalWork( + status: GitStatus | null | undefined, +): boolean { + if (!status) return false; + if (status.conflicted.length > 0 || status.renamed.length > 0) return true; + return [ + ...status.modified, + ...status.created, + ...status.deleted, + ...status.not_added, + ...status.staged, + ].some((path) => !isGeneratedArtifactPath(path)); +} + /** * CMS artifacts live under a `.deco/` directory. The `/.deco/` (and `/.deco`) * forms also match projects whose package path isn't the repo root From 3ab09722645f1a7cf0f29c1d6a3a8e846905f453 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 15:56:36 -0300 Subject: [PATCH 3/8] refactor(fast-preview): submit-for-review wording, icons on every action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Request approval" was invented copy for an action vibecoding already names. Reuse `thread.headerActions.submitForReview` instead — one fewer string, an existing pt-br translation, and the same action stops having two names depending on which toolbar you are in. The state it leads to follows the verb: "Waiting for approval" → "Waiting for review", so you no longer submit for review and then wait for approval. Icons on the primary and every menu entry: a rocket for publish, the repo's own GitHub mark for both GitHub links, and RefreshCw01 for Get latest — the glyph the Sync button already uses for the same operation. Chosen by `action` in the renderer so the state machine stays free of JSX. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 16 ++++++++++++++++ .../thread/github/cms-panel-state.test.ts | 12 ++++++------ .../components/thread/github/cms-panel-state.ts | 4 ++-- apps/web/src/i18n/en/thread.ts | 3 +-- apps/web/src/i18n/pt-br/thread.ts | 3 +-- packages/ui/src/components/split-button.tsx | 3 +++ 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index eefedbc7d8..79569e7200 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -36,6 +36,8 @@ import { } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; +import { GitPullRequest, RefreshCw01, Rocket02 } from "@untitledui/icons"; +import { GitHubIcon } from "@/components/icons/github-icon.tsx"; import { useT } from "@/i18n/use-t"; import { authClient } from "@/lib/auth-client.ts"; import { resolveGithubAttachment } from "@/lib/github-repo.ts"; @@ -62,6 +64,16 @@ interface Props { virtualMcpId: string; } +/** `open-pr` covers both the GitHub links; `key` separates them from each other. */ +function actionIcon(action: CmsAction, key?: string) { + if (action === "open-pr" || key === "resolve-on-github") { + return ; + } + if (action === "publish") return ; + if (action === "get-latest") return ; + return ; +} + export function CmsHeaderActions({ virtualMcpId }: Props) { const t = useT(); const { org } = useProjectContext(); @@ -280,6 +292,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const items: SplitButtonMenuItem[] = button.menu.map((item) => ({ key: item.key, label: item.label, + icon: actionIcon(item.action, item.key), ...(item.tooltip ? { tooltip: item.tooltip } : {}), onSelect: () => dispatch(item.action), })); @@ -295,6 +308,9 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { disabled={Boolean(button.disabled) || !action} loading={Boolean(button.loading)} pulse={Boolean(button.pulse)} + {...(action === "publish" && !button.loading + ? { icon: } + : {})} {...(button.tooltip ? { tooltip: button.tooltip } : {})} items={items} menuAriaLabel={t("thread.cmsActions.moreActionsAriaLabel")} diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index c3001921ab..369c806174 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -216,7 +216,7 @@ describe("selectCmsHeaderButton — 3. needs attention (conflicts)", () => { }); describe("selectCmsHeaderButton — 4. waiting for approval", () => { - test("open PR + blocked → Waiting for approval (opens the PR)", () => { + test("open PR + blocked → Waiting for review (opens the PR)", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -224,7 +224,7 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { reviews: reviews({ mergeableState: "blocked" }), }), ); - expect(r.label).toBe("Waiting for approval"); + expect(r.label).toBe("Waiting for review"); expect(r.action).toBe("open-pr"); expect(r.variant).toBe("outline"); expect(r.disabled).toBeFalsy(); @@ -233,7 +233,7 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { ]); }); - test("open PR + draft (clean mergeable state) → Waiting for approval", () => { + test("open PR + draft (clean mergeable state) → Waiting for review", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -241,7 +241,7 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { reviews: reviews({ draft: true }), }), ); - expect(r.label).toBe("Waiting for approval"); + expect(r.label).toBe("Waiting for review"); expect(r.action).toBe("open-pr"); }); @@ -440,7 +440,7 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { }); describe("selectCmsHeaderButton — 6. draft (no open PR)", () => { - test("ahead of base, no PR → Review & Publish + Request approval", () => { + test("ahead of base, no PR → Review & Publish + Submit for review", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 3 }) }), ); @@ -450,7 +450,7 @@ describe("selectCmsHeaderButton — 6. draft (no open PR)", () => { expect(r.menu).toEqual([ { key: "request-approval", - label: "Request approval", + label: "Submit for review", action: "request-approval", }, ]); diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index f1548fc0d0..72d134ac68 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -234,7 +234,7 @@ export function selectCmsHeaderButton( if (mergeableState === "blocked" || reviews?.draft) { return applyCheckTreatment( { - label: t("thread.cmsActions.waitingForApproval"), + label: t("thread.cmsActions.waitingForReview"), action: "open-pr", variant: "outline", menu: withGetLatest([viewOnGithubItem(t)], branch, t), @@ -277,7 +277,7 @@ export function selectCmsHeaderButton( [ { key: "request-approval", - label: t("thread.cmsActions.requestApproval"), + label: t("thread.headerActions.submitForReview"), action: "request-approval", }, ], diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 23458ee530..18f7f82fc4 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -50,11 +50,10 @@ export const thread = { "thread.cmsActions.getLatestTooltip": "Bring in new changes from production", "thread.cmsActions.moreActionsAriaLabel": "More actions", "thread.cmsActions.publishing": "Publishing…", - "thread.cmsActions.requestApproval": "Request approval", "thread.cmsActions.resolveOnGithub": "Resolve on GitHub", "thread.cmsActions.reviewAndPublish": "Review & Publish", "thread.cmsActions.viewOnGithub": "View on GitHub", - "thread.cmsActions.waitingForApproval": "Waiting for approval", + "thread.cmsActions.waitingForReview": "Waiting for review", "thread.gitTab.by": "by @{author}", "thread.gitTab.closed": "✗ Closed", "thread.gitTab.couldNotLoadPrState": diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 647388b1f9..0394981453 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -54,11 +54,10 @@ export const thread = { "Trazer as novas alterações da produção", "thread.cmsActions.moreActionsAriaLabel": "Mais ações", "thread.cmsActions.publishing": "Publicando…", - "thread.cmsActions.requestApproval": "Pedir aprovação", "thread.cmsActions.resolveOnGithub": "Resolver no GitHub", "thread.cmsActions.reviewAndPublish": "Revisar e publicar", "thread.cmsActions.viewOnGithub": "Ver no GitHub", - "thread.cmsActions.waitingForApproval": "Aguardando aprovação", + "thread.cmsActions.waitingForReview": "Aguardando revisão", "thread.gitTab.by": "por @{author}", "thread.gitTab.closed": "✗ Fechado", "thread.gitTab.couldNotLoadPrState": diff --git a/packages/ui/src/components/split-button.tsx b/packages/ui/src/components/split-button.tsx index 425c65bf86..54c48a4ebe 100644 --- a/packages/ui/src/components/split-button.tsx +++ b/packages/ui/src/components/split-button.tsx @@ -24,6 +24,8 @@ export interface SplitButtonMenuItem { onSelect: () => void; disabled?: boolean; tooltip?: string; + /** Rendered before the label. */ + icon?: React.ReactNode; } export interface SplitButtonProps { @@ -58,6 +60,7 @@ function SplitButtonMenuEntry({ item }: { item: SplitButtonMenuItem }) { item.onSelect(); }} > + {item.icon} {item.label} ); From 7fe017750ba18ce13846d0c8eb4803666882b225 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 16:04:56 -0300 Subject: [PATCH 4/8] fix(fast-preview): surface a failed branch-status read instead of spinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching branches could leave the button on "Loading…" permanently. The selector collapsed "still fetching" and "failed permanently" into one state via `branch.kind !== "ready"`, so a status read that errored — the native API answers `not_ready` for a branch whose local repo isn't set up yet (local-api/src/routes/git.rs:329) — presented as a spinner that never resolves. TanStack had already stopped retrying; nothing was in flight. A failed read now renders "Retry" with the reason as its tooltip. Branch metadata that did arrive still wins, so a stale error can't mask real state. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 17 +++++++--- .../thread/github/cms-panel-state.test.ts | 33 +++++++++++++++++++ .../thread/github/cms-panel-state.ts | 16 ++++++++- apps/web/src/i18n/en/thread.ts | 1 + apps/web/src/i18n/pt-br/thread.ts | 1 + 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index 79569e7200..cb15191f9d 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -70,7 +70,9 @@ function actionIcon(action: CmsAction, key?: string) { return ; } if (action === "publish") return ; - if (action === "get-latest") return ; + if (action === "get-latest" || action === "retry-status") { + return ; + } return ; } @@ -260,6 +262,12 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { reviews: reviewsQuery.data ?? null, publishing, saving, + statusError: + statusQuery.error instanceof Error + ? statusQuery.error.message + : statusQuery.error + ? String(statusQuery.error) + : null, loading: isPrStateActivelyLoading(prQuery), t, }); @@ -281,6 +289,9 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { if (!githubHeadBranch || getLatest.isPending) return; getLatest.mutate({ branch: githubHeadBranch, base: baseBranch }); return; + case "retry-status": + void statusQuery.refetch(); + return; case "open-pr": if (pr?.htmlUrl) { window.open(pr.htmlUrl, "_blank", "noopener,noreferrer"); @@ -308,9 +319,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { disabled={Boolean(button.disabled) || !action} loading={Boolean(button.loading)} pulse={Boolean(button.pulse)} - {...(action === "publish" && !button.loading - ? { icon: } - : {})} + {...(action && !button.loading ? { icon: actionIcon(action) } : {})} {...(button.tooltip ? { tooltip: button.tooltip } : {})} items={items} menuAriaLabel={t("thread.cmsActions.moreActionsAriaLabel")} diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index 369c806174..6da59ace8a 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -52,6 +52,7 @@ function input( publishing: false, saving: false, loading: false, + statusError: null, t: mockT, ...over, }; @@ -596,6 +597,38 @@ describe("selectCmsHeaderButton — Get latest in every menu when behind", () => }); }); +describe("status read failure", () => { + test("a failed status read offers Retry instead of spinning forever", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + statusError: "repository not initialized", + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.retry"]); + expect(r.action).toBe("retry-status"); + expect(r.loading).toBeFalsy(); + expect(r.disabled).toBeFalsy(); + expect(r.tooltip).toBe("repository not initialized"); + }); + + test("a stale error never overrides branch metadata that did arrive", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + statusError: "repository not initialized", + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); + + test("no error still reads as Loading while the branch is unknown", () => { + const r = selectCmsHeaderButton(input({ branch: { kind: "unknown" } })); + expect(r.label).toBe(threadEn["thread.headerActions.loading"]); + expect(r.loading).toBe(true); + }); +}); + describe("saving", () => { test("an in-flight block write holds the button", () => { const r = selectCmsHeaderButton( diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index 72d134ac68..ab9d799cac 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -19,7 +19,8 @@ export type CmsAction = | "publish" | "request-approval" | "get-latest" - | "open-pr"; + | "open-pr" + | "retry-status"; /** One entry of the split button's dropdown half. `key` is the React key. */ export interface CmsMenuItem { @@ -64,6 +65,8 @@ export interface SelectCmsHeaderButtonInput { saving: boolean; /** Branch/PR/check data is still being fetched. */ loading: boolean; + /** Why the branch status could not be read, if it could not. */ + statusError: string | null; t: TFunction; } @@ -176,6 +179,17 @@ export function selectCmsHeaderButton( ): CmsHeaderButton { const { branch, pr, checks, reviews, publishing, saving, loading, t } = input; + // A failed status read is not a slow one; spinning on it never resolves. + if (branch.kind !== "ready" && input.statusError) { + return { + label: t("thread.cmsActions.retry"), + action: "retry-status", + variant: "outline", + tooltip: input.statusError, + menu: [], + }; + } + // Fetching and "branch metadata not here yet" are one state to the editor. if (loading || branch.kind !== "ready") { return { diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 18f7f82fc4..4de73554e3 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -50,6 +50,7 @@ export const thread = { "thread.cmsActions.getLatestTooltip": "Bring in new changes from production", "thread.cmsActions.moreActionsAriaLabel": "More actions", "thread.cmsActions.publishing": "Publishing…", + "thread.cmsActions.retry": "Retry", "thread.cmsActions.resolveOnGithub": "Resolve on GitHub", "thread.cmsActions.reviewAndPublish": "Review & Publish", "thread.cmsActions.viewOnGithub": "View on GitHub", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 0394981453..0dc4b75b98 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -54,6 +54,7 @@ export const thread = { "Trazer as novas alterações da produção", "thread.cmsActions.moreActionsAriaLabel": "Mais ações", "thread.cmsActions.publishing": "Publicando…", + "thread.cmsActions.retry": "Tentar novamente", "thread.cmsActions.resolveOnGithub": "Resolver no GitHub", "thread.cmsActions.reviewAndPublish": "Revisar e publicar", "thread.cmsActions.viewOnGithub": "Ver no GitHub", From 0c7609c576ed50626a8224f256844d3cdc5396b6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 16:22:59 -0300 Subject: [PATCH 5/8] fix(fast-preview): settle the button once instead of correcting it in view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching branches rendered "Review & Publish" brand-green and then recoloured it to warning a beat later. Not a race — a waterfall. `checks` and `reviews` are gated on the PR number, so they cannot start until `usePrByBranch` returns, while `loading` reflected only that PR query. The button therefore committed to a confident, fully actionable state from data still missing the one signal that can contradict it. A UI that corrects itself in front of the user reads as broken even when it is converging. `isCmsStateSettling` holds one window until every query the state depends on has landed, so the button goes from Loading to its final answer and stays there. No extra requests — the same waterfall, just not narrated. Scoped to an open PR, since that is the only case where the dependent queries run at all: a Draft branch has no checks to wait for. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 16 ++++- .../thread/github/cms-panel-state.test.ts | 63 +++++++++++++++++++ .../thread/github/cms-panel-state.ts | 34 +++++++++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index cb15191f9d..4f025725d3 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -46,8 +46,11 @@ import { useProjectContext, useVirtualMCP } from "@/sdk"; import { resolveFastPreview } from "@/sdk/fast-preview"; import { decofileWriteMutationKey } from "../../sections-editor/decofile-api.ts"; import { useChatTask } from "../../chat/index"; -import { selectCmsHeaderButton, type CmsAction } from "./cms-panel-state.ts"; -import { isPrStateActivelyLoading } from "./panel-state.ts"; +import { + isCmsStateSettling, + selectCmsHeaderButton, + type CmsAction, +} from "./cms-panel-state.ts"; import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; import { fetchGitStatus, @@ -147,6 +150,13 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { prNumber: pr && pr.state === "open" ? pr.number : null, }); + const settling = isCmsStateSettling({ + pr, + prQuery, + checksQuery, + reviewsQuery, + }); + const refreshPrState = async () => { await Promise.all([ prQuery.refetch(), @@ -268,7 +278,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { : statusQuery.error ? String(statusQuery.error) : null, - loading: isPrStateActivelyLoading(prQuery), + loading: Boolean(settling), t, }); diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index 6da59ace8a..86f75cbf92 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { BranchMeta } from "@decocms/sandbox/shared"; import { + isCmsStateSettling, selectCmsHeaderButton, type SelectCmsHeaderButtonInput, } from "./cms-panel-state"; @@ -597,6 +598,68 @@ describe("selectCmsHeaderButton — Get latest in every menu when behind", () => }); }); +describe("isCmsStateSettling", () => { + const idle = { isPending: false, fetchStatus: "idle" }; + const inFlight = { isPending: true, fetchStatus: "fetching" }; + + test("the PR query alone can hold the window", () => { + expect( + isCmsStateSettling({ + pr: null, + prQuery: inFlight, + checksQuery: idle, + reviewsQuery: idle, + }), + ).toBe(true); + }); + + test("checks still in flight keep it settling — the flicker this prevents", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: inFlight, + reviewsQuery: idle, + }), + ).toBe(true); + }); + + test("reviews still in flight keep it settling", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: idle, + reviewsQuery: inFlight, + }), + ).toBe(true); + }); + + test("settled once all three are idle", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: idle, + reviewsQuery: idle, + }), + ).toBe(false); + }); + + test("without an open PR the dependent queries never run, so they cannot hold it", () => { + for (const p of [null, pr({ state: "closed", merged: true })]) { + expect( + isCmsStateSettling({ + pr: p, + prQuery: idle, + checksQuery: inFlight, + reviewsQuery: inFlight, + }), + ).toBe(false); + } + }); +}); + describe("status read failure", () => { test("a failed status read offers Retry instead of spinning forever", () => { const r = selectCmsHeaderButton( diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index ab9d799cac..8a7becfc33 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -2,7 +2,11 @@ import type { BranchMeta } from "@decocms/sandbox/shared"; import type { TFunction } from "@/i18n/use-t.ts"; -import { isCheckFailed, isCheckInProgress } from "./panel-state.ts"; +import { + isCheckFailed, + isCheckInProgress, + isPrStateActivelyLoading, +} from "./panel-state.ts"; import type { CheckRun, PrSummary } from "./use-pr-data.ts"; import type { PrReviewSignals } from "./use-pr-reviews.ts"; @@ -166,6 +170,34 @@ function isLevelWithMergedPr( ); } +interface QueryLoadState { + isPending: boolean; + fetchStatus: string; +} + +/** + * Whether the PR picture is still assembling, as ONE window rather than three. + * + * `checks` and `reviews` cannot start until `pr` returns their number, so + * treating only `pr` as loading paints a confident state from data still + * missing the part that can contradict it — and a failing check landing a beat + * later recolours a green button to warning in front of the editor, which + * reads as a bug rather than as convergence. + */ +export function isCmsStateSettling(input: { + pr: PrSummary | null; + prQuery: QueryLoadState; + checksQuery: QueryLoadState; + reviewsQuery: QueryLoadState; +}): boolean { + if (isPrStateActivelyLoading(input.prQuery)) return true; + if (input.pr?.state !== "open" || input.pr.merged) return false; + return ( + isPrStateActivelyLoading(input.checksQuery) || + isPrStateActivelyLoading(input.reviewsQuery) + ); +} + /** Not live yet: one `/git/status` backend commits each save, the other doesn't. */ function hasUnpublishedWork( branch: Extract, From 773dad2fbe104224675f19cafa697603f1b90ca9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 16:32:09 -0300 Subject: [PATCH 6/8] fix(fast-preview): in-flight feedback on every action, and no idle animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Get latest" ran a server-side merge with no feedback at all — the branch was being rewritten under the editor while the button looked idle and clickable. It now holds the control with "Getting latest…" and a spinner, and Retry spins while its refetch is in flight. Publishing still outranks both. Also drops the pulse from "Review & Publish". It was meant to say "checks are running, you may still act", but animating a button the editor is free to click reads as something being wrong with it. Only a genuine wait animates now; the tooltip already carried the progress, so nothing is lost. `pulse` leaves the CMS descriptor entirely — SplitButton keeps the prop for other callers. Co-Authored-By: Claude Opus 5 (1M context) --- .../thread/github/cms-header-actions.tsx | 3 +- .../thread/github/cms-panel-state.test.ts | 58 +++++++++++++------ .../thread/github/cms-panel-state.ts | 39 ++++++++----- apps/web/src/i18n/en/thread.ts | 1 + apps/web/src/i18n/pt-br/thread.ts | 1 + 5 files changed, 70 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index 4f025725d3..7b7c56bcd1 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -272,6 +272,8 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { reviews: reviewsQuery.data ?? null, publishing, saving, + syncing: getLatest.isPending, + statusRetrying: statusQuery.isFetching && !!statusQuery.error, statusError: statusQuery.error instanceof Error ? statusQuery.error.message @@ -328,7 +330,6 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { variant={button.variant} disabled={Boolean(button.disabled) || !action} loading={Boolean(button.loading)} - pulse={Boolean(button.pulse)} {...(action && !button.loading ? { icon: actionIcon(action) } : {})} {...(button.tooltip ? { tooltip: button.tooltip } : {})} items={items} diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index 86f75cbf92..af43c1d7ef 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -54,6 +54,8 @@ function input( saving: false, loading: false, statusError: null, + syncing: false, + statusRetrying: false, t: mockT, ...over, }; @@ -247,7 +249,7 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { expect(r.action).toBe("open-pr"); }); - test("no checks → outline, no spinner, no pulse, no tooltip", () => { + test("no checks → outline, no spinner, no tooltip", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -257,11 +259,10 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { ); expect(r.variant).toBe("outline"); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); expect(r.tooltip).toBeUndefined(); }); - test("all checks passed → outline, no spinner, no pulse", () => { + test("all checks passed → outline, no spinner", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -272,7 +273,6 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { ); expect(r.variant).toBe("outline"); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); expect(r.tooltip).toBeUndefined(); }); @@ -286,7 +286,6 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { }), ); expect(r.loading).toBe(true); - expect(r.pulse).toBeFalsy(); expect(r.tooltip).toBe("Running checks 1 of 2 done"); expect(r.variant).toBe("outline"); }); @@ -305,7 +304,6 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { expect(r.disabled).toBeFalsy(); expect(r.action).toBe("open-pr"); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); }); test("mixed failed + running → running wins (spinner, outline)", () => { @@ -318,7 +316,6 @@ describe("selectCmsHeaderButton — 4. waiting for approval", () => { }), ); expect(r.loading).toBe(true); - expect(r.pulse).toBeFalsy(); expect(r.variant).toBe("outline"); expect(r.tooltip).toBe("Running checks 2 of 3 done"); }); @@ -364,7 +361,7 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { }, ); - test("no checks → brand, no spinner, no pulse, no tooltip", () => { + test("no checks → brand, no spinner, no tooltip", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -374,11 +371,10 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { ); expect(r.variant).toBe("brand"); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); expect(r.tooltip).toBeUndefined(); }); - test("all checks passed → brand, no spinner, no pulse", () => { + test("all checks passed → brand, no spinner", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -389,11 +385,10 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { ); expect(r.variant).toBe("brand"); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); expect(r.tooltip).toBeUndefined(); }); - test("check running → pulse (not spinner) + progress tooltip", () => { + test("check running → still, tooltip only (never animates a clickable button)", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -402,7 +397,6 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { reviews: reviews(), }), ); - expect(r.pulse).toBe(true); expect(r.loading).toBeFalsy(); expect(r.tooltip).toBe("Running checks 1 of 2 done"); expect(r.variant).toBe("brand"); @@ -422,10 +416,9 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { expect(r.tooltip).toBe("2 of 3 checks are not passing"); expect(r.action).toBe("publish"); expect(r.disabled).toBeFalsy(); - expect(r.pulse).toBeFalsy(); }); - test("mixed failed + running → running wins (pulse, brand)", () => { + test("mixed failed + running → running wins (brand, no warning)", () => { const r = selectCmsHeaderButton( input({ branch: ready({ aheadOfBase: 2 }), @@ -434,7 +427,6 @@ describe("selectCmsHeaderButton — 5. ready to publish", () => { reviews: reviews(), }), ); - expect(r.pulse).toBe(true); expect(r.loading).toBeFalsy(); expect(r.variant).toBe("brand"); expect(r.tooltip).toBe("Running checks 1 of 2 done"); @@ -492,7 +484,6 @@ describe("selectCmsHeaderButton — 6. draft (no open PR)", () => { expect(r.variant).toBe("brand"); expect(r.tooltip).toBeUndefined(); expect(r.loading).toBeFalsy(); - expect(r.pulse).toBeFalsy(); }); }); @@ -692,6 +683,39 @@ describe("status read failure", () => { }); }); +describe("in-flight actions", () => { + test("a Get latest merge holds the button with a spinner", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + syncing: true, + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.gettingLatest"]); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("publishing outranks syncing", () => { + const r = selectCmsHeaderButton(input({ publishing: true, syncing: true })); + expect(r.label).toBe(threadEn["thread.cmsActions.publishing"]); + }); + + test("a retry in flight spins on the Retry button", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + statusError: "repository not initialized", + statusRetrying: true, + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.retry"]); + expect(r.loading).toBe(true); + expect(r.disabled).toBe(true); + }); +}); + describe("saving", () => { test("an in-flight block write holds the button", () => { const r = selectCmsHeaderButton( diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index 8a7becfc33..4bdf720fc2 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -38,11 +38,9 @@ export interface CmsMenuItem { * Descriptor returned by {@link selectCmsHeaderButton}. * * An absent `action` means the primary half is an inert status pill. `loading` - * puts a spinner in the primary half and reads as "wait"; `pulse` animates the - * whole control and reads as "something is happening, but you may still act" — - * the two are deliberately never both set. `disabled` and a non-empty `menu` - * can coexist: "Up to date" has nothing to publish yet still offers - * "Get latest" when the branch is behind. + * puts a spinner in the primary half and is reserved for a genuine wait. + * `disabled` and a non-empty `menu` can coexist: "Up to date" has nothing to + * publish yet still offers "Get latest" when the branch is behind. */ export interface CmsHeaderButton { label: string; @@ -52,8 +50,6 @@ export interface CmsHeaderButton { disabled?: boolean; /** Spinner in the primary half. */ loading?: boolean; - /** Whole control pulses. */ - pulse?: boolean; tooltip?: string; menu: CmsMenuItem[]; } @@ -67,6 +63,10 @@ export interface SelectCmsHeaderButtonInput { publishing: boolean; /** A block write is in flight; the branch state is mid-change. */ saving: boolean; + /** A "Get latest" merge is in flight. */ + syncing: boolean; + /** A failed status read is being retried. */ + statusRetrying: boolean; /** Branch/PR/check data is still being fetched. */ loading: boolean; /** Why the branch status could not be read, if it could not. */ @@ -113,15 +113,13 @@ function withGetLatest( * judgement call the editor is allowed to make, so it only recolours to * `warning` and explains itself in the tooltip. * - * `runningTreatment` splits the two states: "Waiting for approval" is a - * genuine wait, so it takes a spinner; "Ready to publish" stays actionable, so - * it pulses instead — a spinner there would wrongly imply the editor should - * hold off. + * Only a genuine wait animates. A button the editor may click right now must + * sit still; the tooltip carries the progress. */ function applyCheckTreatment( button: CmsHeaderButton, checks: CheckRun[], - runningTreatment: "loading" | "pulse", + runningTreatment: "loading" | "none", t: TFunction, ): CmsHeaderButton { const total = checks.length; @@ -133,7 +131,7 @@ function applyCheckTreatment( const tooltip = t("thread.cmsActions.checksRunning", { done, total }); return runningTreatment === "loading" ? { ...button, loading: true, tooltip } - : { ...button, pulse: true, tooltip }; + : { ...button, tooltip }; } const failed = checks.filter(isCheckFailed).length; @@ -217,6 +215,8 @@ export function selectCmsHeaderButton( label: t("thread.cmsActions.retry"), action: "retry-status", variant: "outline", + disabled: input.statusRetrying, + loading: input.statusRetrying, tooltip: input.statusError, menu: [], }; @@ -243,6 +243,17 @@ export function selectCmsHeaderButton( }; } + /** A merge is rewriting the branch under the editor. */ + if (input.syncing) { + return { + label: t("thread.cmsActions.gettingLatest"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + /** Publishing mid-write would ship whichever half of the edit landed. */ if (saving) { return { @@ -300,7 +311,7 @@ export function selectCmsHeaderButton( menu: withGetLatest([viewOnGithubItem(t)], branch, t), }, checks, - "pulse", + "none", t, ); } diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 4de73554e3..7319cd74a5 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -50,6 +50,7 @@ export const thread = { "thread.cmsActions.getLatestTooltip": "Bring in new changes from production", "thread.cmsActions.moreActionsAriaLabel": "More actions", "thread.cmsActions.publishing": "Publishing…", + "thread.cmsActions.gettingLatest": "Getting latest…", "thread.cmsActions.retry": "Retry", "thread.cmsActions.resolveOnGithub": "Resolve on GitHub", "thread.cmsActions.reviewAndPublish": "Review & Publish", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 0dc4b75b98..24a6c83a9f 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -54,6 +54,7 @@ export const thread = { "Trazer as novas alterações da produção", "thread.cmsActions.moreActionsAriaLabel": "Mais ações", "thread.cmsActions.publishing": "Publicando…", + "thread.cmsActions.gettingLatest": "Obtendo atualizações…", "thread.cmsActions.retry": "Tentar novamente", "thread.cmsActions.resolveOnGithub": "Resolver no GitHub", "thread.cmsActions.reviewAndPublish": "Revisar e publicar", From daab2b9bf36544ebd118f8d365043d4e1bb7067e Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 16:41:04 -0300 Subject: [PATCH 7/8] fix(fast-preview): await status invalidation so saves don't flash a stale state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing an up-to-date branch went "Saving…" → "Up to date" → "Review & Publish". The save hooks fired their status invalidation with `void`, so the mutation resolved before the re-read landed. Every observer of "is a save in flight" — the header button, the preview's autosave indicator — was released onto the PREVIOUS status and rendered it as current: a clean, confident "Up to date" over an edit that already existed. Awaiting the invalidation makes the mutation mean what its observers read it to mean. Preferred over holding the button on `statusQuery.isFetching`, which is also true for a window-focus refetch and would have flashed "Saving…" when nothing was being saved — the same class of bug in a new place. Audit of the neighbouring transitions found one more: with no task branch the status query is disabled, so its data never arrives and the button sat on "Loading…" permanently. There is nothing to publish without a branch, so it renders nothing instead. The other paths are already covered: TanStack v5 awaits onSuccess before settling a mutation, so `getLatest` and the post-publish branch switch both span their own invalidations; a branch switch drops the status data, so the Loading state — not a stale one — is what shows. Co-Authored-By: Claude Opus 5 (1M context) --- .../sections-editor/use-delete-block.ts | 10 +++++-- .../sections-editor/use-save-block.ts | 15 +++++++--- .../thread/github/cms-header-actions.tsx | 7 +++++ .../thread/github/cms-panel-state.test.ts | 28 +++++++++++++++++++ .../thread/github/cms-panel-state.ts | 7 ++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index 9bcdc0a9e1..122032d0c1 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -46,9 +46,13 @@ export function useDeleteBlock({ { delete: [blockKey] }, ); setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft); - // Same as use-save-block: the commit moved the head, refresh the - // header's branch meta in place of any interval polling. - void queryClient.invalidateQueries({ + /** + * Same as use-save-block: the commit moved the head, refresh the + * header's branch meta in place of any interval polling — and await + * it, so observers of this mutation aren't released onto a status + * that is still the pre-delete one. + */ + await queryClient.invalidateQueries({ queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch), }); return { ok: true as const, existed: true }; diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index a6ad712d6a..29f8693db5 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -54,10 +54,17 @@ export function useSaveBlock({ { set: { [blockKey]: data } }, ); setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft); - // The landed commit moved the branch head — refresh the header's - // branch meta now. This write is the ONLY in-app head mutation, which - // is what lets the status query drop interval polling entirely. - void queryClient.invalidateQueries({ + /** + * The landed commit moved the branch head — refresh the header's + * branch meta now. This write is the ONLY in-app head mutation, which + * is what lets the status query drop interval polling entirely. + * + * Awaited, not fired and forgotten: observers key "is a save in + * flight" off this mutation, and releasing them before the re-read + * lands renders the PREVIOUS status as if it were current — a clean + * "Up to date" over an edit that already exists. + */ + await queryClient.invalidateQueries({ queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch), }); return draft; diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index 7b7c56bcd1..c81d77432a 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -252,6 +252,13 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { ); } if (!githubRepo) return null; + /** + * No task branch yet: the status query is disabled, so its data never + * arrives and the state machine would sit on "Loading…" forever. There is + * also nothing to publish without a branch — render nothing rather than a + * spinner that resolves to nothing. + */ + if (!branch) return null; /** * Only the tail of the publish — the branch switch that follows the merge. diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts index af43c1d7ef..3b23ab1bd1 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.test.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -716,6 +716,34 @@ describe("in-flight actions", () => { }); }); +describe("post-write status refresh", () => { + test("a save still in flight never renders the pre-write status", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 0, workingTreeDirty: false }), + saving: true, + }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.saving"]); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("releases to the real state once the re-read lands", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 1 }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); + + test("a genuinely clean branch still settles on Up to date", () => { + const r = selectCmsHeaderButton(input({ branch: ready() })); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + }); +}); + describe("saving", () => { test("an in-flight block write holds the button", () => { const r = selectCmsHeaderButton( diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts index 4bdf720fc2..f3b4148480 100644 --- a/apps/web/src/components/thread/github/cms-panel-state.ts +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -254,7 +254,12 @@ export function selectCmsHeaderButton( }; } - /** Publishing mid-write would ship whichever half of the edit landed. */ + /** + * Spans the re-read too: the write hooks await their status invalidation, so + * this stays true until the fresh branch meta is in hand — otherwise the + * previous status renders as if current, a clean "Up to date" over an edit + * that already exists. Publishing mid-write would ship half an edit. + */ if (saving) { return { label: t("thread.headerActions.saving"), From edbbfb6b6d162b94cc0e6e66c1d8547a64067058 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 13 Aug 2026 16:17:03 -0300 Subject: [PATCH 8/8] fix(native): Fast Preview is authoritative over the sandbox interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app answered `/api/:org/sandbox/*/git/*` from a local sandbox unconditionally (intercept/sandbox_ops.rs `is_handled` matches `["git", _]`), and `try_intercept` runs before any upstream forwarding — so the cloud's Fast Preview branch (api sandbox-proxy.ts:213) was never reached in the native app. There are zero references to fastPreview anywhere in apps/native/crates. sandbox_ops already declines when no local worktree exists, and its comment names Fast Preview as a case that falls through. It doesn't, because the worktree handle is derived from the REPOSITORY: a sandbox left over from vibecoding on the same repo claims the route for a branch it never checked out, then answers `repository not initialized` (routes/git.rs:329). So the flag has to be declared, not inferred. The webview sets `x-deco-fast-preview` on its sandbox git calls and try_intercept declines every `/sandbox/*` path carrying it. A routing hint, not a trust boundary: the API re-derives the flag from the vMCP's own metadata, so a wrong value can only route toward the authority, never around it. apps/api needed no change — resolveVmClaim returns before resolveSandboxProvider is ever called, so a Fast Preview project never resolves a runner and daemon-backed routes 503 via requireRunner. It was already authoritative; it was being bypassed. Co-Authored-By: Claude Opus 5 (1M context) --- .../local-api/src/routes/intercept/mod.rs | 90 +++++++++++++++---- .../crates/local-api/src/routes/upstream.rs | 11 ++- .../thread/github/cms-header-actions.tsx | 6 +- .../thread/github/sandbox-git-api.ts | 30 ++++++- 4 files changed, 117 insertions(+), 20 deletions(-) diff --git a/apps/native/crates/local-api/src/routes/intercept/mod.rs b/apps/native/crates/local-api/src/routes/intercept/mod.rs index a837df34f6..cc0c507f80 100644 --- a/apps/native/crates/local-api/src/routes/intercept/mod.rs +++ b/apps/native/crates/local-api/src/routes/intercept/mod.rs @@ -27,6 +27,7 @@ //! | `POST /api/:org/tools/COLLECTION_THREAD_MESSAGES_LIST` | §3.1 | [`thread_tools`] | //! | `GET /api/:org/watch` | local thread lifecycle SSE | [`watch`] | //! | `POST /api/:org/sandbox/:virtualMcpId/:branch/{read,write,unlink,mkdir,rename,glob,grep}` | native sandbox filesystem bridge | [`sandbox_fs`] | +//! | any `/api/:org/sandbox/*` carrying [`FAST_PREVIEW_HEADER`] | sandbox-less by definition | never intercepted (`None`) | //! | any `/api/:org/decopilot/*` | retired native chat transport | local `410`, never forwarded | //! | any other `/api/:org/tools/:toolName` | — | not intercepted (`None`) | //! @@ -73,21 +74,27 @@ pub(crate) use sandbox_lifecycle::{ pub(crate) mod watch; use axum::body::Bytes; -use axum::http::Method; +use axum::http::{HeaderMap, Method}; use axum::response::{IntoResponse, Response}; use crate::error::ApiError; use crate::state::AppState; -/// The single entry point `routes/upstream.rs::proxy` calls before its -/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare -/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or -/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to -/// strip anymore) with its query string supplied separately for `/watch`'s -/// optional `types` filter. Other intercepted routes do not read query -/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride -/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim" -/// contract, map §3.1. +/// Header the webview sets when the project it is acting for is Fast Preview. +/// +/// A routing hint, not a trust boundary: it only decides whether to answer +/// LOCALLY, and upstream re-derives the flag from the vMCP's own metadata +/// before serving anything. A wrong value can therefore only send the request +/// to the authority, never around it. +pub const FAST_PREVIEW_HEADER: &str = "x-deco-fast-preview"; + +fn declares_fast_preview(headers: &HeaderMap) -> bool { + headers + .get(FAST_PREVIEW_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + /// Whether `` in `/api//` is really an organization. /// /// NOT every second path segment is one: `/api/config`, `/api/auth/*` and the @@ -103,12 +110,22 @@ fn is_org_scoped(segment: &str, rest: &[&str]) -> bool { !segment.starts_with('_') && rest.first() == Some(&"tools") } +/// The single entry point `routes/upstream.rs::proxy` calls before its +/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare +/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or +/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to +/// strip anymore) with its query string supplied separately for `/watch`'s +/// optional `types` filter. Other intercepted routes do not read query +/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride +/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim" +/// contract, map §3.1. pub async fn try_intercept( state: &AppState, method: &Method, path: &str, query: Option<&str>, body: &Bytes, + headers: &HeaderMap, ) -> Option { let mut segs = path.trim_start_matches('/').split('/'); if segs.next()? != "api" { @@ -117,6 +134,17 @@ pub async fn try_intercept( let org = segs.next().filter(|s| !s.is_empty())?; let rest: Vec<&str> = segs.collect(); + // Fast Preview is sandbox-less by definition, so nothing under + // `/sandbox/*` can be answered from this machine — upstream serves those + // routes from the GitHub API. Declining here rather than letting each + // sandbox interceptor decide is what makes the flag authoritative: the + // worktree handle is derived from the REPOSITORY, so a desktop sandbox + // left over from vibecoding on the same repo otherwise claims the route + // for a branch it has never checked out. + if rest.first().copied() == Some("sandbox") && declares_fast_preview(headers) { + return None; + } + // Start warming this organization's filesystem. A genuinely org-scoped // request here is exactly "the app booted into this org" or "the user // switched to it", so the mounts come up while the user is still @@ -254,6 +282,30 @@ mod tests { assert!(is_org_scoped("gimenes-guarana-works", &["tools", "X"])); } + /// The regression: the worktree handle is derived from the REPOSITORY, so + /// a desktop sandbox left over from vibecoding on the same repo claimed + /// `/sandbox/*/git/*` for a Fast Preview branch it had never checked out + /// and answered `repository not initialized` — forever, since the query + /// stopped retrying. + #[test] + fn fast_preview_is_declared_by_the_header_only_when_set() { + use axum::http::{HeaderMap, HeaderValue}; + + let mut on = HeaderMap::new(); + on.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("1")); + assert!(super::declares_fast_preview(&on)); + + let mut worded = HeaderMap::new(); + worded.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("TRUE")); + assert!(super::declares_fast_preview(&worded)); + + let mut off = HeaderMap::new(); + off.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("0")); + assert!(!super::declares_fast_preview(&off)); + + assert!(!super::declares_fast_preview(&HeaderMap::new())); + } + use super::*; #[tokio::test] @@ -266,6 +318,7 @@ mod tests { "/api/acme/tools/SOME_OTHER_TOOL", None, &Bytes::from_static(b"{}"), + &HeaderMap::new(), ) .await; assert!(res.is_none()); @@ -275,17 +328,23 @@ mod tests { async fn non_org_scoped_paths_are_not_intercepted() { let dir = tempfile::tempdir().unwrap(); let state = test_state(dir.path()); - assert!( - try_intercept(&state, &Method::GET, "/api/config", None, &Bytes::new(),) - .await - .is_none() - ); + assert!(try_intercept( + &state, + &Method::GET, + "/api/config", + None, + &Bytes::new(), + &HeaderMap::new(), + ) + .await + .is_none()); assert!(try_intercept( &state, &Method::GET, "/api/auth/get-session", None, &Bytes::new(), + &HeaderMap::new(), ) .await .is_none()); @@ -301,6 +360,7 @@ mod tests { "/api/acme/decopilot/some-future-route", None, &Bytes::new(), + &HeaderMap::new(), ) .await; let res = res diff --git a/apps/native/crates/local-api/src/routes/upstream.rs b/apps/native/crates/local-api/src/routes/upstream.rs index 6f6a3a8d65..c7df58b985 100644 --- a/apps/native/crates/local-api/src/routes/upstream.rs +++ b/apps/native/crates/local-api/src/routes/upstream.rs @@ -171,8 +171,15 @@ pub async fn proxy(State(state): State, req: Request) -> Response { // whether there's a valid session), so it must never wait on, or be // gated by, this proxy's auth machinery. See that module's doc comment // for the full route table and the map citations behind each entry. - if let Some(response) = - intercept::try_intercept(&state, &parts.method, &path, parts.uri.query(), &body_bytes).await + if let Some(response) = intercept::try_intercept( + &state, + &parts.method, + &path, + parts.uri.query(), + &body_bytes, + &parts.headers, + ) + .await { return response; } diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index c81d77432a..4764c0d35a 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -100,7 +100,10 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { /** Poll-free on purpose: every call forwards to GitHub; save hooks invalidate this key. */ const statusQuery = useQuery({ queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, branch ?? ""), - queryFn: () => fetchGitStatus(org.slug, virtualMcpId, branch ?? ""), + queryFn: () => + fetchGitStatus(org.slug, virtualMcpId, branch ?? "", { + fastPreview: true, + }), enabled: !!branch, staleTime: 15_000, }); @@ -191,6 +194,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { target.branch, target.base, { onConflict: "branch-wins" }, + { fastPreview: true }, ); return target; }, diff --git a/apps/web/src/components/thread/github/sandbox-git-api.ts b/apps/web/src/components/thread/github/sandbox-git-api.ts index 7842940cb1..8cb25f35a7 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.ts @@ -88,18 +88,42 @@ async function parseJson(res: Response): Promise { return body; } +/** + * Tells the desktop app not to answer this call from a local sandbox. + * + * Fast Preview is sandbox-less, so `/sandbox/*` must reach the API that serves + * it from GitHub. The desktop interceptor keys its worktree handle off the + * REPOSITORY, so a sandbox left over from vibecoding on the same repo would + * otherwise claim the route. A hint only — the API re-derives the flag from + * the vMCP's metadata, so a wrong value routes to the authority, never past it. + */ +export const FAST_PREVIEW_HEADER = "x-deco-fast-preview"; + +export interface SandboxGitCallOptions { + fastPreview?: boolean; +} + /** Never cache sandbox git/fs calls — 410 Gone must not stick in disk cache. */ -function sandboxFetch(url: string, init?: RequestInit): Promise { - return fetch(url, { cache: "no-store", ...init }); +function sandboxFetch( + url: string, + init?: RequestInit, + call?: SandboxGitCallOptions, +): Promise { + const headers = new Headers(init?.headers); + if (call?.fastPreview) headers.set(FAST_PREVIEW_HEADER, "1"); + return fetch(url, { cache: "no-store", ...init, headers }); } export async function fetchGitStatus( orgSlug: string, virtualMcpId: string, branch: string, + call?: SandboxGitCallOptions, ): Promise { const res = await sandboxFetch( buildSandboxGitUrl(orgSlug, virtualMcpId, branch, "status"), + undefined, + call, ); return parseJson(res); } @@ -201,6 +225,7 @@ export async function rebaseGitBranch( */ onConflict?: "branch-wins"; }, + call?: SandboxGitCallOptions, ): Promise { const res = await sandboxFetch( buildSandboxGitUrl(orgSlug, virtualMcpId, branch, "rebase"), @@ -209,6 +234,7 @@ export async function rebaseGitBranch( headers: { "content-type": "application/json" }, body: JSON.stringify({ base, ...opts }), }, + call, ); await parseJson(res); }