From aeb9b1054eef6ad89c48155d243dc6169300399f Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 2 Sep 2026 03:38:05 -0300 Subject: [PATCH 1/5] feat(drafts): Draft & Releases mode behind a per-agent flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in "Draft & Releases mode" (metadata.draftsMode, default off) that replaces the classic branch/PR picker with a named-drafts UX: - Releases switcher: create ("Novo Rascunho", auto-numbered), rename, discard, and "Advanced" (adopt a specific branch or open PR as a draft). - Read-only production: the CMS stays navigable but every value widget is inert with a "start a new draft to edit" tooltip; mutations disabled. - Publish → production + discard the merged draft (CMS and coding session). - Optimistic switcher writes (create/rename/delete feel instant). - New GITHUB_DELETE_BRANCH tool (refuses to delete the default branch). - Settings toggle to enable it per code agent. Off by default: agents keep the classic branch/PR picker and post-publish behavior — no visible change unless the flag is enabled. Also raises the dev embedded-postgres max_connections to 500 to avoid a boot-time "too many clients" retry storm. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/services/ensure-services.ts | 2 + apps/api/src/tools/github/delete-branch.ts | 108 +++ apps/api/src/tools/github/graphql.ts | 2 +- apps/api/src/tools/github/index.ts | 1 + apps/api/src/tools/index.ts | 1 + apps/web/src/components/chat/input.tsx | 14 + .../src/components/chat/pills/branch-pill.tsx | 82 +- .../chat/pills/chat-mode-row.test.tsx | 20 + .../components/chat/pills/chat-mode-row.tsx | 53 +- .../sandbox/blocks/blocks-panel.tsx | 105 ++- .../sandbox/content/content-browser.tsx | 14 +- .../sections-editor/fields/array-field.tsx | 5 +- .../sections-editor/fields/array-row.tsx | 8 +- .../sections-editor/fields/field-props.ts | 2 + .../fields/read-only-context.ts | 15 + .../sections-editor/fields/read-only-pane.tsx | 54 ++ .../sections-editor/schema-form.tsx | 43 +- .../sections-editor/section-list.tsx | 13 +- .../sections-editor-panels.tsx | 4 + .../thread/github/branch-picker-legacy.tsx | 506 ++++++++++ .../thread/github/branch-picker.tsx | 865 ++++++++++-------- .../thread/github/cms-header-actions.tsx | 37 +- .../thread/github/header-actions.tsx | 11 + .../thread/github/start-draft-cta.tsx | 47 + .../components/thread/github/use-releases.ts | 103 +++ .../thread/github/use-version-gate.ts | 83 ++ apps/web/src/i18n/en/chat.ts | 2 + apps/web/src/i18n/en/sections-editor.ts | 2 + apps/web/src/i18n/en/thread.ts | 12 + apps/web/src/i18n/en/virtual-mcp.ts | 3 + apps/web/src/i18n/pt-br/chat.ts | 2 + apps/web/src/i18n/pt-br/sections-editor.ts | 2 + apps/web/src/i18n/pt-br/thread.ts | 12 + apps/web/src/i18n/pt-br/virtual-mcp.ts | 3 + .../views/virtual-mcp/drafts-mode-field.tsx | 56 ++ apps/web/src/views/virtual-mcp/index.tsx | 5 + packages/shared/src/sdk/types/index.ts | 2 + packages/shared/src/sdk/types/virtual-mcp.ts | 39 + .../shared/src/tools/registry-metadata.ts | 1 + packages/shared/src/tools/tool-io.ts | 108 +++ 40 files changed, 1933 insertions(+), 514 deletions(-) create mode 100644 apps/api/src/tools/github/delete-branch.ts create mode 100644 apps/web/src/components/sections-editor/fields/read-only-context.ts create mode 100644 apps/web/src/components/sections-editor/fields/read-only-pane.tsx create mode 100644 apps/web/src/components/thread/github/branch-picker-legacy.tsx create mode 100644 apps/web/src/components/thread/github/start-draft-cta.tsx create mode 100644 apps/web/src/components/thread/github/use-releases.ts create mode 100644 apps/web/src/components/thread/github/use-version-gate.ts create mode 100644 apps/web/src/views/virtual-mcp/drafts-mode-field.tsx diff --git a/apps/api/src/services/ensure-services.ts b/apps/api/src/services/ensure-services.ts index 795064dd9d..e4e5ebde95 100644 --- a/apps/api/src/services/ensure-services.ts +++ b/apps/api/src/services/ensure-services.ts @@ -339,6 +339,8 @@ async function ensurePostgres(home: string): Promise { user: PG_USER, password: PG_PASSWORD, persistent: true, + // Headroom for the API's many boot-time pools; default 100 storm-retries. + postgresFlags: ["-c", "max_connections=500"], onLog: (msg: string) => { if (process.env.DEBUG_SERVICES) console.log(`[pg] ${msg}`); }, diff --git a/apps/api/src/tools/github/delete-branch.ts b/apps/api/src/tools/github/delete-branch.ts new file mode 100644 index 0000000000..811061db09 --- /dev/null +++ b/apps/api/src/tools/github/delete-branch.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { githubConnectionAccessToken } from "@/oauth/github-mint"; +import { RECONNECT_ERROR } from "@/oauth/token-refresh"; +import { resolveGithubConnection } from "./graphql"; + +/** e2e seam: set GITHUB_API_BASE_URL to a local stub (mirrors github-git-data). */ +function githubApiBaseUrl(): string { + return process.env.GITHUB_API_BASE_URL ?? "https://api.github.com"; +} + +const GITHUB_TIMEOUT_MS = 15_000; + +function githubHeaders(token: string): HeadersInit { + return { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "studio-github", + }; +} + +/** Encode a branch ref segment-by-segment so `feat/x` stays a path, not `%2F`. */ +function encodeRef(branch: string): string { + return branch.split("/").map(encodeURIComponent).join("/"); +} + +/** + * Delete a repository branch (git ref). App-only, connection-scoped. Refuses to + * delete the repository's default branch — that is production ("Produção"), the + * live version people branch off, never a discardable draft. A missing ref is + * treated as already-deleted so the tool is idempotent. + */ +export const GITHUB_DELETE_BRANCH = defineTool({ + name: "GITHUB_DELETE_BRANCH", + description: + "Delete a branch (git ref) from a repository. Refuses to delete the repository's default (production) branch.", + annotations: { + title: "Delete GitHub Branch", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + connectionId: z.string().describe("ID of the mcp-github connection to use"), + owner: z.string().describe("Repository owner (user or org login)"), + repo: z.string().describe("Repository name"), + branch: z + .string() + .describe("Branch name to delete (no `refs/heads/` prefix)"), + }), + outputSchema: z.object({ deleted: z.boolean() }), + handler: async (input, ctx) => { + await ctx.access.check(); + + const branch = input.branch.trim(); + if (!branch) { + throw new Error("Branch name is required"); + } + + const connection = await resolveGithubConnection(ctx, input.connectionId); + const token = await githubConnectionAccessToken(ctx, connection); + if (!token) { + throw new Error(RECONNECT_ERROR); + } + + const repoLabel = `${input.owner}/${input.repo}`; + const base = githubApiBaseUrl(); + + // Never delete the live/default branch — read it fresh, don't trust input. + const repoRes = await fetch(`${base}/repos/${input.owner}/${input.repo}`, { + headers: githubHeaders(token), + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }); + if (!repoRes.ok) { + throw new Error(`Couldn't read ${repoLabel} (${repoRes.status})`); + } + const repoJson = (await repoRes.json()) as { default_branch?: string }; + if (repoJson.default_branch === branch) { + throw new Error( + `Refusing to delete the production branch "${branch}" of ${repoLabel}`, + ); + } + + const delRes = await fetch( + `${base}/repos/${input.owner}/${input.repo}/git/refs/heads/${encodeRef(branch)}`, + { + method: "DELETE", + headers: githubHeaders(token), + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }, + ); + // 204 = deleted; 422/404 = ref already gone — idempotent success. + if ( + delRes.status !== 204 && + delRes.status !== 422 && + delRes.status !== 404 + ) { + const text = await delRes.text().catch(() => ""); + throw new Error( + `Failed to delete branch "${branch}": ${delRes.status} ${text.slice(0, 200)}`, + ); + } + + return { deleted: true }; + }, +}); diff --git a/apps/api/src/tools/github/graphql.ts b/apps/api/src/tools/github/graphql.ts index da9540a1c3..40d9066f28 100644 --- a/apps/api/src/tools/github/graphql.ts +++ b/apps/api/src/tools/github/graphql.ts @@ -91,7 +91,7 @@ export function unwrapGraphqlData( * caller-supplied, so it is re-read scoped to the authenticated org before its * credential is used — no cross-org token reads (as GITHUB_LIST_USER_ORGS does). */ -async function resolveGithubConnection( +export async function resolveGithubConnection( ctx: StudioContext, connectionId: string, ) { diff --git a/apps/api/src/tools/github/index.ts b/apps/api/src/tools/github/index.ts index a4dca4a302..32f69cfd67 100644 --- a/apps/api/src/tools/github/index.ts +++ b/apps/api/src/tools/github/index.ts @@ -9,3 +9,4 @@ export { GITHUB_LIST_USER_ORGS } from "./list-user-orgs"; export { GITHUB_SEARCH_BRANCHES } from "./search-branches"; export { GITHUB_PR_STATE } from "./pr-state"; export { GITHUB_LAST_PUBLISHED_PR } from "./last-published-pr"; +export { GITHUB_DELETE_BRANCH } from "./delete-branch"; diff --git a/apps/api/src/tools/index.ts b/apps/api/src/tools/index.ts index 5c03cdcde4..013bcd8ebe 100644 --- a/apps/api/src/tools/index.ts +++ b/apps/api/src/tools/index.ts @@ -251,6 +251,7 @@ export const CORE_TOOLS = [ GitHubTools.GITHUB_SEARCH_BRANCHES, GitHubTools.GITHUB_PR_STATE, GitHubTools.GITHUB_LAST_PUBLISHED_PR, + GitHubTools.GITHUB_DELETE_BRANCH, // Link tools diff --git a/apps/web/src/components/chat/input.tsx b/apps/web/src/components/chat/input.tsx index b4fe753abc..87861098b4 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -16,6 +16,11 @@ import { useVirtualMCP, } from "@/sdk"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; +import { + draftsModeEnabled, + useIsOnProduction, +} from "@/components/thread/github/use-version-gate"; +import { StartDraftCta } from "@/components/thread/github/start-draft-cta"; import { useNavigate } from "@tanstack/react-router"; import { ArrowRight, @@ -389,6 +394,10 @@ export function ChatInput({ const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; const selectedVm = useVirtualMCP(selectedVirtualMcp?.id); const fastPreviewActive = useSessionRuntime(selectedVm?.id).runtime === "cms"; + const isOnProduction = useIsOnProduction( + selectedVm, + taskCtx?.currentBranch ?? null, + ); const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); const { unsupportedFile, onUnsupportedFile, clearUnsupportedFile } = @@ -630,6 +639,11 @@ export function ChatInput({ return ; } + // Production is the read-only live version — editing means branching off it. + if (draftsModeEnabled(selectedVm) && isOnProduction && taskCtx) { + return ; + } + if (hostedRuntimeBlocked) { return ( void; + onCreateBranch?: (branch: string) => void; + locked: boolean; + placement?: "chat" | "header"; + /** Drafts-mode only: production branch shown as "Produção". */ + baseBranch?: string | null; + /** Classic-picker only: repo scope for listing branches/PRs. */ orgId: string; orgSlug: string; userId: string; - userLabel: string | null | undefined; - virtualMcpId: string; connectionId: string | null; owner: string; repo: string; sandboxMap: SandboxMap | undefined; - value: string | null | undefined; - onChange: (branch: string) => void; - onCreateBranch?: (branch: string) => void; - locked: boolean; - placement?: "chat" | "header"; } -/** Thin wrapper over `BranchPicker`: a `locked` chat has a fixed branch, so any - * pick/create opens a new chat on it instead of switching (`spawnsNewChat`). */ -export function BranchPill({ locked, placement, value, ...props }: Props) { +/** Routes to the drafts switcher or the classic branch/PR picker by the + * per-agent flag. A `locked` chat has a fixed branch, so any pick/create opens + * a new chat on it instead of switching (`spawnsNewChat`). */ +export function BranchPill({ + draftsMode, + locked, + placement, + value, + virtualMcpId, + userLabel, + baseBranch, + onChange, + onCreateBranch, + orgId, + orgSlug, + userId, + connectionId, + owner, + repo, + sandboxMap, +}: Props) { + if (draftsMode) { + return ( + + ); + } return ( - ); } diff --git a/apps/web/src/components/chat/pills/chat-mode-row.test.tsx b/apps/web/src/components/chat/pills/chat-mode-row.test.tsx index 8aae5d58db..5b6f33c3ac 100644 --- a/apps/web/src/components/chat/pills/chat-mode-row.test.tsx +++ b/apps/web/src/components/chat/pills/chat-mode-row.test.tsx @@ -20,6 +20,18 @@ mock.module("../../thread/github/branch-picker", () => ({ ), })); +mock.module("../../thread/github/branch-picker-legacy", () => ({ + BranchPickerLegacy: ({ spawnsNewChat }: { spawnsNewChat?: boolean }) => ( + + ), +})); + import { ChatModeRowPure } from "./chat-mode-row"; import { BranchPill } from "./branch-pill"; @@ -51,6 +63,7 @@ describe("ChatModeRowPure", () => { }); const BRANCH_PILL_PROPS = { + draftsMode: true, orgId: "org-1", orgSlug: "my-org", userId: "user-1", @@ -81,4 +94,11 @@ describe("BranchPill", () => { "false", ); }); + + it("renders the classic picker when draftsMode is off", () => { + const { getByTestId } = renderWithQueryClient( + , + ); + expect(getByTestId("legacy")).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/chat/pills/chat-mode-row.tsx b/apps/web/src/components/chat/pills/chat-mode-row.tsx index fb8d3195ee..4ebc804a49 100644 --- a/apps/web/src/components/chat/pills/chat-mode-row.tsx +++ b/apps/web/src/components/chat/pills/chat-mode-row.tsx @@ -2,12 +2,16 @@ import type { ReactNode } from "react"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { useOptionalChatStream, useOptionalChatTask } from "../context"; import { BranchPill } from "./branch-pill"; +import { + draftsModeEnabled, + useBaseBranch, +} from "../../thread/github/use-version-gate"; import { getActiveGithubRepo } from "@/lib/github-repo"; +import { useProjectContext } from "@/sdk"; import { defaultThreadRuntime, readThreadRuntime, } from "@decocms/shared/thread/session-runtime"; -import { useProjectContext } from "@/sdk"; import { authClient } from "@/lib/auth-client"; import { branchUserLabel } from "@decocms/shared/branch-name"; @@ -62,42 +66,45 @@ export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { const connectionId = githubRepo?.connectionId; const { data: session } = authClient.useSession(); - const userId = session?.user?.id ?? ""; const userLabel = branchUserLabel(session?.user); + const userId = session?.user?.id ?? ""; const { org } = useProjectContext(); + // Production branch shown as "Produção"; one shared source with the gate. + const baseBranch = useBaseBranch(virtualMcp, currentBranch); + const draftsMode = draftsModeEnabled(virtualMcp); + + // Locked chat's branch is fixed: open a new chat on the picked branch. + const onChange = (next: string) => { + if (locked && createTask) createTask({ branch: next }); + else if (setCurrentTaskBranch) void setCurrentTaskBranch(next); + }; + // Locked or CMS→sandbox: branch off into a fresh thread, don't re-point. + const onCreateBranch = (next: string) => { + if ((locked || createBranchAsCms) && createTask) + createTask({ branch: next }); + else if (setCurrentTaskBranch) void setCurrentTaskBranch(next); + }; + const branchPill = githubRepo && connectionId ? ( { - // Locked chat's branch is fixed: open a new chat on the picked branch. - if (locked && createTask) { - createTask({ branch: next }); - } else if (setCurrentTaskBranch) { - void setCurrentTaskBranch(next); - } - }} - onCreateBranch={(next) => { - // Locked or CMS→sandbox: branch off into a fresh thread, don't re-point. - if ((locked || createBranchAsCms) && createTask) { - createTask({ branch: next }); - } else if (setCurrentTaskBranch) { - void setCurrentTaskBranch(next); - } - }} + onChange={onChange} + onCreateBranch={onCreateBranch} locked={locked} placement="chat" /> diff --git a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx index 26dec72e74..ed42cf6bf6 100644 --- a/apps/web/src/components/sandbox/blocks/blocks-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/blocks-panel.tsx @@ -1,6 +1,11 @@ -import { Suspense, lazy } from "react"; +import { Suspense, lazy, type ReactNode } from "react"; import { Loading01 } from "@untitledui/icons"; -import { useProjectContext } from "@/sdk"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { + draftsModeEnabled, + useIsOnProduction, +} from "@/components/thread/github/use-version-gate"; +import { ReadOnlyPane } from "@/components/sections-editor/fields/read-only-pane"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { useChatTask } from "@/components/chat/context"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; @@ -50,6 +55,9 @@ export function BlocksPanel({ }) { const { org } = useProjectContext(); const { currentBranch, taskId } = useChatTask(); + const readOnlyVm = useVirtualMCP(virtualMcpId); + const isOnProduction = useIsOnProduction(readOnlyVm, currentBranch); + const readOnly = draftsModeEnabled(readOnlyVm) && isOnProduction; const sandboxEvents = useSandboxEvents(); const lifecycle = useSandboxLifecycle(); const workspace = useBlocksPreviewWorkspace(); @@ -95,6 +103,15 @@ export function BlocksPanel({ return ; } + // On production the editor stays visible but read-only per widget. + const gateReadOnly = (children: ReactNode) => ( +
+ + {children} + +
+ ); + // Blocks edits whatever page its sibling Preview canvas is on. The shared // workspace target is published by Preview; when Preview hasn't run yet, // fall back to the last visited page persisted for this project + branch. @@ -102,21 +119,16 @@ export function BlocksPanel({ // Loaders have their own editor (form + Run), not the sections editor. if (target?.kind === "loader" && decofile.data && meta.data) { - return ( -
- -
+ return gateReadOnly( + , ); } @@ -146,38 +158,33 @@ export function BlocksPanel({ ? `section:${activeGlobalBlockKey}` : `path:${currentPath}`; - return ( -
- - -
+ return gateReadOnly( + + + + } + > + - - - + onExitSeo={workspace.consumeEditSeo} + onVariantPreviewOverride={workspace.setVariantOverride} + onViewJsonFile={onViewJsonFile} + /> + , ); } diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index cf3146d904..a53aafa57e 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -34,7 +34,12 @@ import { } from "@decocms/ui/components/tooltip.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; import { useT } from "@/i18n/use-t.ts"; -import { useProjectContext } from "@/sdk"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { + draftsModeEnabled, + useIsOnProduction, +} from "@/components/thread/github/use-version-gate"; +import { ReadOnlyPane } from "@/components/sections-editor/fields/read-only-pane"; import { useInsetContext } from "@/layouts/agent-shell-layout"; import { useChatTask } from "@/components/chat/context"; import { useDecofile } from "@/components/sections-editor/use-decofile"; @@ -348,6 +353,9 @@ function ContentBrowserReady({ }) { const t = useT(); const threadId = useOptionalChatTask()?.taskId ?? null; + const readOnlyVm = useVirtualMCP(virtualMcpId); + const isOnProduction = useIsOnProduction(readOnlyVm, branch); + const readOnly = draftsModeEnabled(readOnlyVm) && isOnProduction; const fetchParams = { orgSlug, virtualMcpId, branch, threadId, previewUrl }; const { data: decofile, isLoading: decofileLoading } = useDecofile( fetchParams, @@ -1175,7 +1183,7 @@ function ContentBrowserReady({ } /> )} -
+ {activeCollection === "loaders" || activeCollection === "actions" ? ( )} -
+ {/* Page create/duplicate/rename dialog */} {pageDialog && ( diff --git a/apps/web/src/components/sections-editor/fields/array-field.tsx b/apps/web/src/components/sections-editor/fields/array-field.tsx index 4560931867..af793cc780 100644 --- a/apps/web/src/components/sections-editor/fields/array-field.tsx +++ b/apps/web/src/components/sections-editor/fields/array-field.tsx @@ -20,6 +20,7 @@ import { import { Plus } from "@untitledui/icons"; import { toast } from "sonner"; import { useT } from "@/i18n/use-t.ts"; +import { useIsReadOnly } from "./read-only-context"; import { SORTABLE_DROP_ANIMATION } from "@/lib/dnd-drop-animation.ts"; import { cn } from "@decocms/ui/lib/utils.ts"; import { @@ -127,6 +128,7 @@ export function ArrayField({ sandbox, }: FieldProps) { const t = useT(); + const readOnly = useIsReadOnly(); const tooltipsEnabled = useFieldDescriptionTooltips(sandbox?.virtualMcpId); const items = Array.isArray(value) ? value : []; const itemSchema = schema.items; @@ -608,8 +610,9 @@ export function ArrayField({ + + + + {label} + + + +
+ + {tab === "branches" && ( + + )} +
+ {spawnsNewChat && ( +

+ {t("thread.branchPicker.newChatHint")} +

+ )} + { + setTab(v as "branches" | "prs"); + setSearch(""); + }} + > + + + {t("thread.branchPicker.branchesTab")} + + + {t("thread.branchPicker.prsTab")} + + + + + {tab === "prs" ? ( + <> + {prsError && ( +
+ {t("thread.branchPicker.couldntLoadPullRequests")} +
+ )} + {prsLoading && ( +
+ {t("thread.branchPicker.loadingPullRequests")} +
+ )} + {!prsError && !prsLoading && ( + + {search.trim() + ? t("thread.branchPicker.noPullRequestsFound") + : t("thread.branchPicker.noOpenPullRequests")} + + )} + {openablePrs.length > 0 && ( + + {openablePrs.map((pr) => ( + pick(pr.head)} + > + +
+ + {decodeHtmlEntities(pr.title)} + + + #{pr.number} · {pr.head} + {pr.author ? ` · @${pr.author}` : ""} + +
+
+ ))} +
+ )} + {hiddenForkPrs > 0 && ( +
+ {t("thread.branchPicker.hiddenForkPrs", { + count: hiddenForkPrs, + })} +
+ )} + + ) : ( + <> + {isError && ( +
+ {t("thread.branchPicker.couldntLoadBranches")} +
+ )} + {!isError && (isSearching || !isLoading) && ( + + {isSearching + ? t("thread.branchPicker.searchingBranches") + : t("thread.branchPicker.noBranchesFound")} + + )} + {recent.length > 0 && ( + + {recent.map((b) => ( + pick(b.name)} + > + + {b.name} + {b.name === value && ( + + )} + + + ))} + + )} + {yours.length > 0 && ( + <> + {recent.length > 0 && } + + {yours.map((b) => ( + pick(b.name)} + > + + {b.name} + {b.name === value && ( + + )} + + ))} + + + )} + {others.length > 0 && ( + <> + + + {others.map((b) => ( + pick(b.name)} + > + + {b.name} + {b.author && ( + + @{b.author} + + )} + {b.name === value && ( + + )} + + ))} + + + )} + {hasMore && ( +
+ +
+ )} + {hiddenMatchCount > 0 && ( +
+ {t("thread.branchPicker.moreMatches", { + count: hiddenMatchCount, + })} +
+ )} + {!search.trim() && !hasMore && others.length > 0 && ( +
+ {t("thread.branchPicker.allLoaded")} +
+ )} + + )} +
+
+
+ + ); +} + +type MemberUser = { name?: string | null; image?: string | null }; +type OrgMember = { userId: string; user?: MemberUser }; + +/** + * Overlapping avatar stack for the people with an active sandbox on a branch. + * Shows up to 3 faces, then a "+N" chip. Unknown userIds fall back to initials. + */ +function ContributorAvatars({ + userIds, + memberById, +}: { + userIds: string[]; + memberById: Map; +}) { + if (userIds.length === 0) return null; + const shown = userIds.slice(0, 3); + const extra = userIds.length - shown.length; + + return ( + + {shown.map((uid) => { + const user = memberById.get(uid); + return ( + + ); + })} + {extra > 0 && ( + + +{extra} + + )} + + ); +} diff --git a/apps/web/src/components/thread/github/branch-picker.tsx b/apps/web/src/components/thread/github/branch-picker.tsx index 8885cffbf6..981dbbb861 100644 --- a/apps/web/src/components/thread/github/branch-picker.tsx +++ b/apps/web/src/components/thread/github/branch-picker.tsx @@ -1,92 +1,98 @@ -import { type UIEvent, useState } from "react"; -import type { SandboxMap } from "@/sdk"; -import { useMembersQuery } from "@/hooks/use-members"; -import { getInitials } from "@/lib/get-initials"; -import { Avatar } from "@decocms/ui/components/avatar.tsx"; +import { useState } from "react"; import { Button } from "@decocms/ui/components/button.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@decocms/ui/components/command.tsx"; import { Popover, PopoverContent, PopoverTrigger, } from "@decocms/ui/components/popover.tsx"; -import { Tabs, TabsList, TabsTrigger } from "@decocms/ui/components/tabs.tsx"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@decocms/ui/components/command.tsx"; import { Check, ChevronDown, + ChevronLeft, + ChevronRight, + DotsVertical, + Edit01, GitBranch01, GitPullRequest, + Plus, + Trash01, } from "@untitledui/icons"; import { generateBranchName } from "@decocms/shared/branch-name"; +import type { Release } from "@decocms/shared/sdk/types"; +import type { SandboxMap } from "@/sdk"; +import { useT } from "@/i18n/use-t.ts"; +import { TOUR_ANCHORS } from "@/components/cms-tour/anchors"; import { decodeHtmlEntities } from "./decode-html-entities.ts"; import { matchesBranchSearch, useBranches } from "./use-branches"; -import { useT } from "@/i18n/use-t.ts"; import { useOpenPrs } from "./use-pr-data.ts"; -import { TOUR_ANCHORS } from "@/components/cms-tour/anchors"; +import { nextReleaseColor, releaseDotClass, useReleases } from "./use-releases"; interface Props { + virtualMcpId: string; + /** Human-readable creator label used to seed the generated branch name. */ + userLabel: string | null | undefined; + /** The current branch (a release's branch, or the base). */ + value: string | null | undefined; + /** The project's production branch — shown as "No ar" (Live). */ + baseBranch?: string | null; + /** Repo scope for the "Advanced" flow (adopt an existing branch/PR as a draft). */ orgId: string; orgSlug: string; userId: string; - /** Human-readable creator label (display name, else email local-part) used to - * seed generated branch names. */ - userLabel: string | null | undefined; - virtualMcpId: string; connectionId: string | null; owner: string; repo: string; sandboxMap: SandboxMap | undefined; - value: string | null | undefined; onChange: (branch: string) => void; - /** Called instead of `onChange` when the user creates a brand-new branch via - * the "New" button, letting callers treat branch *creation* differently from - * switching to an existing branch (Fast Preview projects start a fresh CMS - * thread on it). Falls back to `onChange` when omitted. */ + /** Called instead of `onChange` when a brand-new release is created, so CMS + * projects can start a fresh thread on the new branch. Falls back to + * `onChange`. */ onCreateBranch?: (branch: string) => void; - /** When true, the trigger is disabled — the user can't open the - * picker. The tooltip still surfaces the current branch on hover. */ disabled?: boolean; - /** When true, selecting or creating a branch opens a *new* chat on it rather - * than switching in place — the current thread's branch is fixed (its chat - * has messages). Surfaces a hint so the new-chat behavior isn't surprising. */ + /** When true, picking/creating opens a *new* chat on the branch rather than + * switching in place (the current thread's branch is fixed). */ spawnsNewChat?: boolean; - /** Chat input uses responsive label collapse; header always shows the name. */ + /** Chat input collapses the label responsively; header always shows it. */ placement?: "chat" | "header"; } -/** Substring, not cmdk's fuzzy default, so it can't hide a server match. */ -const branchFilter = (value: string, search: string) => - matchesBranchSearch(value, search) ? 1 : 0; - -/** Grouped branch picker over {@link useBranches}, plus an open-PRs tab. */ +/** Version switcher over {@link useReleases}: a pinned "No ar" (the published + * base) plus the curated list of named, color-coded releases, and an inline + * "Nova versão" create. It is NOT a branch list — only versions people named + * appear here; each is backed by a git branch under the hood. */ export function BranchPicker({ + virtualMcpId, + userLabel, + value, + baseBranch, orgId, orgSlug, userId, - userLabel, - // virtualMcpId is consumed by callers via Props (e.g. BranchPill); - // BranchPicker itself doesn't use it directly. Kept on the Props - // contract so the pill container can pass it down uniformly. - virtualMcpId: _virtualMcpId, connectionId, owner, repo, sandboxMap, - value, onChange, onCreateBranch, disabled = false, @@ -96,90 +102,97 @@ export function BranchPicker({ const t = useT(); const isHeader = placement === "header"; const [open, setOpen] = useState(false); - const [tab, setTab] = useState<"branches" | "prs">("branches"); - const [search, setSearch] = useState(""); - // cmdk highlights the first item by default; seed the active item with the - // current branch so the picker opens focused on the branch in use rather than - // an unrelated first row. Kept in sync with keyboard/hover navigation. - const [activeValue, setActiveValue] = useState(value ?? ""); + const [advanced, setAdvanced] = useState(false); + const [editing, setEditing] = useState(null); + const [editName, setEditName] = useState(""); + const { releases, createRelease, renameRelease, deleteRelease } = + useReleases(virtualMcpId); - const { - recent, - yours, - others, - isLoading, - isError, - isSearching, - hiddenMatchCount, - hasMore, - isFetchingMore, - fetchMore, - } = useBranches({ - orgId, - orgSlug, - userId, - connectionId, - sandboxMap, - owner, - repo, - search: tab === "branches" ? search : "", - enabled: open, - }); + const isBase = !!value && value === baseBranch; + const current = releases.find((r) => r.branch === value); + // Current branch that is neither base nor a stored release: show as a draft. + const unlisted = !isBase && !current && !!value; - // userId -> { name, image } for contributor avatars on recent branches. - // Non-suspense variant so the trigger button never blocks on member loading. - const { data: membersData } = useMembersQuery({ enabled: open }); - const memberById = new Map( - ((membersData?.data?.members ?? []) as OrgMember[]).map( - (m) => [m.userId, m.user] as const, - ), - ); - - // Open PRs for the repo — a PR is just a branch, so selecting one starts a - // sandbox on its head branch, exactly like picking a branch. - const { - data: prs = [], - isLoading: prsLoading, - isError: prsError, - } = useOpenPrs({ - orgId, - orgSlug, - connectionId: connectionId ?? "", - owner, - repo, - enabled: open && tab === "prs", - }); + const currentLabel = isBase + ? t("thread.branchPicker.live") + : (current?.name ?? t("thread.branchPicker.defaultVersionName")); + const label = value ? currentLabel : t("thread.branchPicker.selectVersion"); + const currentDot = isBase + ? "bg-success" + : releaseDotClass(current?.color ?? "orange"); - // Only same-repo PRs are openable: a fork PR's head.ref names a branch in the - // fork, not this repo, so picking it would fail or hit a same-named local - // branch. Hide those, but surface a count so the drop isn't silent. - const repoFullName = `${owner}/${repo}`.toLowerCase(); - const openablePrs = prs.filter( - (pr) => pr.headRepoFullName?.toLowerCase() === repoFullName, - ); - const hiddenForkPrs = prs.length - openablePrs.length; + const pick = (branch: string) => { + onChange(branch); + setOpen(false); + }; - const pick = (name: string) => { - onChange(name); + // Advanced: adopt an existing branch/PR head as a named draft, then switch. + const adoptBranch = (branch: string, name: string) => { + if (!releases.some((r) => r.branch === branch)) { + void createRelease({ + branch, + name: name.trim() || branch, + color: nextReleaseColor(releases.length), + createdAt: new Date().toISOString(), + }); + } + onChange(branch); + setAdvanced(false); setOpen(false); }; - // Creating a branch is a distinct intent from switching to an existing one. - const create = (name: string) => { - (onCreateBranch ?? onChange)(name); + // One above the highest existing "Rascunho N" — renamed releases don't count. + const nextDraftName = () => { + const base = t("thread.branchPicker.defaultVersionName"); + const prefix = `${base} `; + const max = releases.reduce((m, r) => { + if (!r.name.startsWith(prefix)) return m; + const n = Number(r.name.slice(prefix.length)); + return Number.isInteger(n) && n > m ? n : m; + }, 0); + return `${base} ${max + 1}`; + }; + + const create = () => { + const branch = generateBranchName(userLabel); + void createRelease({ + branch, + name: nextDraftName(), + color: nextReleaseColor(releases.length), + createdAt: new Date().toISOString(), + }); + (onCreateBranch ?? onChange)(branch); setOpen(false); }; - const label = value ?? t("thread.branchPicker.selectBranch"); + const resetTransient = () => { + setEditing(null); + setEditName(""); + setAdvanced(false); + }; + + const startRename = (r: Release) => { + setEditing(r.branch); + setEditName(r.name); + }; - const onListScroll = (event: UIEvent) => { - const target = event.currentTarget; - const distanceFromBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; + const saveRename = (branch: string) => { + const next = editName.trim(); + if (next) void renameRelease(branch, next); + setEditing(null); + setEditName(""); + }; - if (tab === "branches" && distanceFromBottom < 48) { - fetchMore(); + const handleDelete = (r: Release) => { + if ( + !window.confirm(t("thread.branchPicker.deleteConfirm", { name: r.name })) + ) { + return; } + // Leave the deleted draft for production before it vanishes from the list. + if (r.branch === value && baseBranch) pick(baseBranch); + else setOpen(false); + void deleteRelease(r.branch); }; return ( @@ -189,14 +202,7 @@ export function BranchPicker({ disabled ? undefined : (next) => { - // Re-seed the highlight on the current branch each time the picker - // opens, in case the branch changed since it was last closed. - // A term left over from last time would silently pre-filter the - // list, and re-fire its request on open. - if (next) { - setActiveValue(value ?? ""); - setSearch(""); - } + if (!next) resetTransient(); setOpen(next); } } @@ -214,17 +220,18 @@ export function BranchPicker({ aria-label={label} disabled={disabled} className={cn( - "font-mono shrink min-w-0 max-w-[200px] gap-1.5", + "shrink min-w-0 max-w-[220px] gap-2", isHeader ? "text-xs" : "text-xs text-muted-foreground hover:text-foreground", )} > - - {/* Show the branch name (truncated) so the branch in use is - visible at a glance. Below 768px of panel header collapse to - an icon-only button — the name stays available via the - tooltip. Container query, matching the rest of the strip. */} + {label} @@ -238,280 +245,360 @@ export function BranchPicker({ {label} - -
- - {tab === "branches" && ( - - )} -
- {spawnsNewChat && ( -

- {t("thread.branchPicker.newChatHint")} -

- )} - { - setTab(v as "branches" | "prs"); - setSearch(""); - }} - > - - - {t("thread.branchPicker.branchesTab")} - - - {t("thread.branchPicker.prsTab")} - - - - - {tab === "prs" ? ( - <> - {prsError && ( -
- {t("thread.branchPicker.couldntLoadPullRequests")} -
- )} - {prsLoading && ( -
- {t("thread.branchPicker.loadingPullRequests")} -
- )} - {!prsError && !prsLoading && ( - - {search.trim() - ? t("thread.branchPicker.noPullRequestsFound") - : t("thread.branchPicker.noOpenPullRequests")} - - )} - {openablePrs.length > 0 && ( - - {openablePrs.map((pr) => ( - pick(pr.head)} - > - -
- - {decodeHtmlEntities(pr.title)} - - - #{pr.number} · {pr.head} - {pr.author ? ` · @${pr.author}` : ""} - -
-
- ))} -
- )} - {hiddenForkPrs > 0 && ( -
- {t("thread.branchPicker.hiddenForkPrs", { - count: hiddenForkPrs, - })} -
- )} - - ) : ( - <> - {isError && ( -
- {t("thread.branchPicker.couldntLoadBranches")} -
- )} - {!isError && (isSearching || !isLoading) && ( - - {isSearching - ? t("thread.branchPicker.searchingBranches") - : t("thread.branchPicker.noBranchesFound")} - - )} - {recent.length > 0 && ( - - {recent.map((b) => ( - pick(b.name)} - > - - {b.name} - {b.name === value && ( - - )} - - - ))} - - )} - {yours.length > 0 && ( - <> - {recent.length > 0 && } - - {yours.map((b) => ( - pick(b.name)} - > - - {b.name} - {b.name === value && ( - - )} - - ))} - - - )} - {others.length > 0 && ( - <> - - - {others.map((b) => ( - pick(b.name)} - > - - {b.name} - {b.author && ( - - @{b.author} - - )} - {b.name === value && ( - - )} - - ))} - - - )} - {hasMore && ( -
-
- )} - {hiddenMatchCount > 0 && ( -
- {t("thread.branchPicker.moreMatches", { - count: hiddenMatchCount, - })} -
- )} - {!search.trim() && !hasMore && others.length > 0 && ( -
- {t("thread.branchPicker.allLoaded")} -
- )} - - )} -
-
+ ) : ( + pick(r.branch)} + onRename={() => startRename(r)} + onDelete={() => void handleDelete(r)} + /> + ), + )} + +
+ + + + )} ); } -type MemberUser = { name?: string | null; image?: string | null }; -type OrgMember = { userId: string; user?: MemberUser }; - -/** - * Overlapping avatar stack for the people with an active sandbox on a branch. - * Shows up to 3 faces, then a "+N" chip. Unknown userIds fall back to initials. - */ -function ContributorAvatars({ - userIds, - memberById, +function VersionRow({ + dot, + label, + selected = false, + disabled = false, + onSelect, }: { - userIds: string[]; - memberById: Map; + dot: string; + label: string; + selected?: boolean; + disabled?: boolean; + onSelect: () => void; }) { - if (userIds.length === 0) return null; - const shown = userIds.slice(0, 3); - const extra = userIds.length - shown.length; + return ( + + ); +} +/** A named release row: click to switch, with a ⋯ menu to rename or discard. */ +function ReleaseRow({ + release, + selected, + onSelect, + onRename, + onDelete, +}: { + release: Release; + selected: boolean; + onSelect: () => void; + onRename: () => void; + onDelete: () => void; +}) { + const t = useT(); return ( - - {shown.map((uid) => { - const user = memberById.get(uid); - return ( - - ); - })} - {extra > 0 && ( - - +{extra} - +
+ > + + + + + + + + + {t("thread.branchPicker.rename")} + + + + {t("thread.branchPicker.delete")} + + + +
+ ); +} + +/** "Advanced": adopt an existing branch or open PR as a named draft. Reuses the + * classic branch/PR listing ({@link useBranches} + {@link useOpenPrs}). */ +function AdvancedPicker({ + orgId, + orgSlug, + userId, + connectionId, + owner, + repo, + sandboxMap, + enabled, + onBack, + onAdopt, +}: { + orgId: string; + orgSlug: string; + userId: string; + connectionId: string | null; + owner: string; + repo: string; + sandboxMap: SandboxMap | undefined; + enabled: boolean; + onBack: () => void; + onAdopt: (branch: string, name: string) => void; +}) { + const t = useT(); + const [search, setSearch] = useState(""); + const { + recent, + yours, + others, + isLoading, + hasMore, + isFetchingMore, + fetchMore, + } = useBranches({ + orgId, + orgSlug, + userId, + connectionId, + sandboxMap, + owner, + repo, + search, + enabled, + }); + const { data: prs = [] } = useOpenPrs({ + orgId, + orgSlug, + connectionId: connectionId ?? "", + owner, + repo, + enabled, + }); + + const repoFullName = `${owner}/${repo}`.toLowerCase(); + const openablePrs = prs.filter( + (pr) => pr.headRepoFullName?.toLowerCase() === repoFullName, + ); + + const seen = new Set(); + const branches = [...recent, ...yours, ...others].filter((b) => { + if (seen.has(b.name)) return false; + seen.add(b.name); + return true; + }); + + return ( +
+ + (matchesBranchSearch(v, s) ? 1 : 0)}> + + { + const el = e.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) { + fetchMore(); + } + }} + > + {isLoading && ( +
+ {t("thread.branchPicker.loadingMore")} +
+ )} + {!isLoading && branches.length === 0 && openablePrs.length === 0 && ( + + {t("thread.branchPicker.noBranchesFound")} + + )} + {branches.length > 0 && ( + + {branches.map((b) => ( + onAdopt(b.name, b.name)} + > + + {b.name} + + ))} + + )} + {openablePrs.length > 0 && ( + <> + + + {openablePrs.map((pr) => ( + + onAdopt(pr.head, decodeHtmlEntities(pr.title)) + } + > + +
+ + {decodeHtmlEntities(pr.title)} + + + #{pr.number} · {pr.head} + +
+
+ ))} +
+ + )} + {hasMore && ( +
+ +
+ )} +
+
+
); } 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 888dbad9d1..6d17c126f0 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -13,10 +13,6 @@ */ 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, @@ -35,7 +31,6 @@ import { GitPullRequest, RefreshCw01, Rocket02 } from "@untitledui/icons"; import { GitHubIcon } from "@/components/icons/github-icon.tsx"; import { useT } from "@/i18n/use-t"; import { track } from "@/lib/posthog-client"; -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"; @@ -67,7 +62,14 @@ import { sandboxGitStatusQueryOptions, } from "./sandbox-git-api.ts"; import { useChecks, useLastPublishedPr, usePrByBranch } from "./use-pr-data.ts"; +import { useReleases } from "./use-releases"; +import { draftsModeEnabled } from "./use-version-gate"; import { usePrReviews } from "./use-pr-reviews.ts"; +import { authClient } from "@/lib/auth-client.ts"; +import { + branchUserLabel, + generateBranchName, +} from "@decocms/shared/branch-name"; interface Props { virtualMcpId: string; @@ -89,8 +91,9 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const t = useT(); const { org } = useProjectContext(); const queryClient = useQueryClient(); - const { data: session } = authClient.useSession(); const vm = useVirtualMCP(virtualMcpId); + const { data: session } = authClient.useSession(); + const { deleteRelease } = useReleases(virtualMcpId); const { currentBranch: branch, setCurrentTaskBranch, @@ -229,17 +232,21 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { await prQuery.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. - */ + /** Publish done: go to production and discard the merged draft (switcher entry + * + branch); a mutation so `isPending` signals the publish is still settling. */ const publishCompletion = useMutation({ mutationFn: async () => { - await setCurrentTaskBranch( - generateBranchName(branchUserLabel(session?.user)), - ); + if (!draftsModeEnabled(vm)) { + await setCurrentTaskBranch( + generateBranchName(branchUserLabel(session?.user)), + ); + return; + } + const published = branch; + await setCurrentTaskBranch(baseBranch); + if (published && published !== baseBranch) { + await deleteRelease(published); + } }, /** The dialog is already closed by now, so a toast is the only surface. */ onError: (err: unknown) => { diff --git a/apps/web/src/components/thread/github/header-actions.tsx b/apps/web/src/components/thread/github/header-actions.tsx index 39b1cef5bb..2d707d0092 100644 --- a/apps/web/src/components/thread/github/header-actions.tsx +++ b/apps/web/src/components/thread/github/header-actions.tsx @@ -25,6 +25,8 @@ import { useChatStream } from "../../chat/chat-context.tsx"; import { useChatTask } from "../../chat/index"; import { usePanelActions } from "@/layouts/shell-layout"; import { squashMergePullRequest } from "./github-pr-api.ts"; +import { useReleases } from "./use-releases"; +import { draftsModeEnabled } from "./use-version-gate"; import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; import { isPrStateActivelyLoading, @@ -135,6 +137,7 @@ export function HeaderActions({ virtualMcpId }: Props) { const { org } = useProjectContext(); const { data: session } = authClient.useSession(); const vm = useVirtualMCP(virtualMcpId); + const { deleteRelease } = useReleases(virtualMcpId); const { currentBranch: branch, setCurrentTaskBranch, taskId } = useChatTask(); const chat = useChatStream(); const { openSidePanel } = usePanelActions(); @@ -333,6 +336,14 @@ export function HeaderActions({ virtualMcpId }: Props) { }; const switchToFreshBranch = async () => { + if (draftsModeEnabled(vm)) { + const published = branch; + await setCurrentTaskBranch(baseBranch); + if (published && published !== baseBranch) { + await deleteRelease(published); + } + return; + } const nextBranch = generateBranchName(branchUserLabel(session?.user)); await setCurrentTaskBranch(nextBranch); }; diff --git a/apps/web/src/components/thread/github/start-draft-cta.tsx b/apps/web/src/components/thread/github/start-draft-cta.tsx new file mode 100644 index 0000000000..61e7c8aee1 --- /dev/null +++ b/apps/web/src/components/thread/github/start-draft-cta.tsx @@ -0,0 +1,47 @@ +import { ArrowRight, Lock01 } from "@untitledui/icons"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { useT } from "@/i18n/use-t.ts"; +import { useCreateDraft } from "./use-version-gate"; + +/** + * "Production is read-only — start a new draft to edit" call-to-action. Shown + * wherever editing is blocked because the current version is production: the + * chat composer slot (`variant="composer"`) and the CMS content editor + * (`variant="panel"`). Clicking creates a new draft and switches editing onto + * it via {@link useCreateDraft}. + */ +export function StartDraftCta({ + virtualMcpId, + variant = "composer", +}: { + virtualMcpId: string; + variant?: "composer" | "panel"; +}) { + const t = useT(); + const createDraft = useCreateDraft(virtualMcpId); + + return ( + + ); +} diff --git a/apps/web/src/components/thread/github/use-releases.ts b/apps/web/src/components/thread/github/use-releases.ts new file mode 100644 index 0000000000..0a58b1d172 --- /dev/null +++ b/apps/web/src/components/thread/github/use-releases.ts @@ -0,0 +1,103 @@ +import { useProjectContext, useVirtualMCP, useVirtualMCPActions } from "@/sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { callStudioTool } from "@/lib/studio-tools"; +import { getActiveGithubRepo } from "@/lib/github-repo"; +import type { Release, VirtualMCPEntity } from "@decocms/shared/sdk/types"; + +/** + * Dot colors a named release can take, in assignment order. `success` (green) is + * intentionally excluded — it's reserved for "Produção" (the published base) so + * the live version always reads as green and never collides with a draft's dot. + */ +const RELEASE_COLORS = ["orange", "violet", "blue", "pink", "amber", "teal"]; + +/** Round-robin the palette by current count so new releases look distinct. */ +export function nextReleaseColor(count: number): string { + return RELEASE_COLORS[count % RELEASE_COLORS.length]!; +} + +const DOT_CLASS: Record = { + orange: "bg-orange-500", + violet: "bg-violet-500", + blue: "bg-blue-500", + pink: "bg-pink-500", + amber: "bg-amber-500", + teal: "bg-teal-500", +}; + +/** Tailwind class for a stored color token; falls back to a neutral dot. */ +export function releaseDotClass(color: string | undefined): string { + return (color && DOT_CLASS[color]) || "bg-muted-foreground"; +} + +type ItemData = { item: VirtualMCPEntity | null }; + +/** + * The curated, branch-backed release list stored at `metadata.releases` — NOT + * the git branch list. Writers patch the cached VIRTUAL_MCP item first so the + * switcher updates instantly, then persist (a failure reverts). Delete also + * removes the git branch so a discarded draft leaves nothing behind. + */ +export function useReleases(virtualMcpId: string) { + const vm = useVirtualMCP(virtualMcpId); + const actions = useVirtualMCPActions(); + const { org } = useProjectContext(); + const queryClient = useQueryClient(); + const releases: Release[] = vm?.metadata?.releases ?? []; + const repo = getActiveGithubRepo(vm); + + const isItemQuery = (queryKey: readonly unknown[]) => + queryKey[1] === org.id && + queryKey[3] === "collection" && + queryKey[4] === "VIRTUAL_MCP" && + queryKey[5] === virtualMcpId; + + const write = (next: Release[]) => { + queryClient.setQueriesData( + { predicate: (q) => isItemQuery(q.queryKey) }, + (old) => + old?.item + ? { + item: { + ...old.item, + metadata: { ...old.item.metadata, releases: next }, + }, + } + : old, + ); + return actions.update + .mutateAsync({ + id: virtualMcpId, + data: { + metadata: { releases: next } as unknown as NonNullable< + VirtualMCPEntity["metadata"] + >, + }, + }) + .catch((err) => { + queryClient.invalidateQueries({ + predicate: (q) => isItemQuery(q.queryKey), + }); + throw err; + }); + }; + + const createRelease = (release: Release) => write([...releases, release]); + + const renameRelease = (branch: string, name: string) => + write(releases.map((r) => (r.branch === branch ? { ...r, name } : r))); + + const deleteRelease = async (branch: string) => { + await write(releases.filter((r) => r.branch !== branch)); + if (repo?.connectionId) { + await callStudioTool(org.slug, "GITHUB_DELETE_BRANCH", { + connectionId: repo.connectionId, + owner: repo.owner, + repo: repo.name, + branch, + }); + } + }; + + return { releases, createRelease, renameRelease, deleteRelease }; +} diff --git a/apps/web/src/components/thread/github/use-version-gate.ts b/apps/web/src/components/thread/github/use-version-gate.ts new file mode 100644 index 0000000000..2a8151e964 --- /dev/null +++ b/apps/web/src/components/thread/github/use-version-gate.ts @@ -0,0 +1,83 @@ +import { useProjectContext } from "@/sdk"; +import { authClient } from "@/lib/auth-client"; +import { getActiveGithubRepo } from "@/lib/github-repo"; +import { + branchUserLabel, + generateBranchName, +} from "@decocms/shared/branch-name"; +import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; +import { useT } from "@/i18n/use-t.ts"; +import { useOptionalChatTask } from "@/components/chat/context"; +import { usePrByBranch } from "./use-pr-data.ts"; +import { nextReleaseColor, useReleases } from "./use-releases"; + +/** + * The project's production branch — the PR base of the current branch, or "main" + * as the app-wide fallback. Shared so "am I on production?" reads one source. + */ +export function useBaseBranch( + virtualMcp: VirtualMCPEntity | null | undefined, + currentBranch: string | null | undefined, +): string { + const { org } = useProjectContext(); + const repo = getActiveGithubRepo(virtualMcp); + return ( + usePrByBranch({ + orgId: org.id, + orgSlug: org.slug, + connectionId: repo?.connectionId ?? "", + owner: repo?.owner ?? "", + repo: repo?.name ?? "", + branch: currentBranch ?? null, + }).data?.base ?? "main" + ); +} + +/** + * Per-agent "Draft & Releases mode" flag. Off (default) keeps the classic + * branch/PR picker and post-publish behavior; on gates the drafts UX. + */ +export function draftsModeEnabled( + virtualMcp: VirtualMCPEntity | null | undefined, +): boolean { + return virtualMcp?.metadata?.draftsMode === true; +} + +/** + * True when the current branch is production (the base) — the read-only live + * version people must branch off to edit. + */ +export function useIsOnProduction( + virtualMcp: VirtualMCPEntity | null | undefined, + currentBranch: string | null | undefined, +): boolean { + const base = useBaseBranch(virtualMcp, currentBranch); + return !!currentBranch && currentBranch === base; +} + +/** + * Creates a new named draft (a release) and switches editing onto it: mints a + * branch, records the release, then re-points the current thread — or starts a + * new one when the thread is locked. Shared by the switcher and the "start a new + * draft to edit" CTAs shown on production. + */ +export function useCreateDraft(virtualMcpId: string) { + const t = useT(); + const { data: session } = authClient.useSession(); + const userLabel = branchUserLabel(session?.user); + const { releases, createRelease } = useReleases(virtualMcpId); + const taskCtx = useOptionalChatTask(); + + return async (name?: string) => { + const branch = generateBranchName(userLabel); + await createRelease({ + branch, + name: name?.trim() || t("thread.branchPicker.defaultVersionName"), + color: nextReleaseColor(releases.length), + createdAt: new Date().toISOString(), + }); + if (taskCtx?.isThreadLocked) taskCtx.createTask({ branch }); + else taskCtx?.setCurrentTaskBranch(branch); + return branch; + }; +} diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index 93d9b4376f..c1307d8be4 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -261,6 +261,8 @@ export const chat = { "chat.input.readOnlyOthersChatNamed": "Read only - you're viewing {name}'s chat", "chat.input.readOnlyThread": "Read only - this chat takes no replies", + "chat.input.productionReadOnly": "Production is read-only", + "chat.input.startDraftToEdit": "Start a new draft to edit", "chat.input.sendMessage": "Send message", "chat.input.sendMessageEnter": "Send message (Enter)", "chat.input.stillSendingPreviousMessage": diff --git a/apps/web/src/i18n/en/sections-editor.ts b/apps/web/src/i18n/en/sections-editor.ts index b888a0e6a8..6d7d02d28e 100644 --- a/apps/web/src/i18n/en/sections-editor.ts +++ b/apps/web/src/i18n/en/sections-editor.ts @@ -168,6 +168,8 @@ export const sectionsEditor = { "sectionsEditor.secretField.secretNamePlaceholder": "Secret name", "sectionsEditor.secretField.secretValuePlaceholder": "Secret value", "sectionsEditor.secretField.storedSecretMessage": "A secret value is stored.", + "sectionsEditor.readOnlyFieldTooltip": + "Production is read-only. Start a new draft to edit.", "sectionsEditor.sectionList.addSectionButton": "Add section", "sectionsEditor.sectionList.addVariantMenuItem": "Add variant", "sectionsEditor.sectionList.deleteMenuItem": "Delete", diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 07dc9c543d..f0be5d5559 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -6,6 +6,18 @@ export const thread = { "thread.branchPicker.couldntLoadPullRequests": "Couldn't load pull requests from GitHub.", "thread.branchPicker.createBranch": 'Create "{name}"', + "thread.branchPicker.advanced": "Advanced", + "thread.branchPicker.advancedBack": "Back", + "thread.branchPicker.defaultVersionName": "Draft", + "thread.branchPicker.delete": "Delete", + "thread.branchPicker.deleteConfirm": + 'Delete "{name}"? This can\'t be undone.', + "thread.branchPicker.live": "Production", + "thread.branchPicker.moreActions": "More actions", + "thread.branchPicker.newVersion": "New draft", + "thread.branchPicker.rename": "Rename", + "thread.branchPicker.save": "Save", + "thread.branchPicker.selectVersion": "Select a version", "thread.branchPicker.newChatHint": "This chat's branch is fixed. Picking or creating a branch opens a new chat on it.", "thread.branchPicker.hiddenForkPrs": diff --git a/apps/web/src/i18n/en/virtual-mcp.ts b/apps/web/src/i18n/en/virtual-mcp.ts index 34c5d76c23..3a2df78574 100644 --- a/apps/web/src/i18n/en/virtual-mcp.ts +++ b/apps/web/src/i18n/en/virtual-mcp.ts @@ -198,6 +198,9 @@ Define step-by-step how the agent should handle requests. 3. Summarize the result and propose next steps. 4. Ask for confirmation before making any changes. `, + "virtualMcp.virtualMcp.draftsModeTitle": "Draft & Releases mode", + "virtualMcp.virtualMcp.draftsModeDescription": + "Replace the branch/PR picker with named drafts: a releases switcher, read-only production, and publish-to-production.", "virtualMcp.virtualMcp.publishing": "Publishing", "virtualMcp.virtualMcp.publishingDescription": "Control when this agent's changes can be published directly, skipping pull-request review.", diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index 51dd1d3d2e..eea6fd7a55 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -270,6 +270,8 @@ export const chat = { "Apenas leitura - você está visualizando o chat de {name}", "chat.input.readOnlyThread": "Apenas leitura - este chat não aceita respostas", + "chat.input.productionReadOnly": "Produção é somente leitura", + "chat.input.startDraftToEdit": "Comece um novo rascunho para editar", "chat.input.sendMessage": "Enviar mensagem", "chat.input.sendMessageEnter": "Enviar mensagem (Enter)", "chat.input.stillSendingPreviousMessage": diff --git a/apps/web/src/i18n/pt-br/sections-editor.ts b/apps/web/src/i18n/pt-br/sections-editor.ts index 11bcd982a8..c0754aadba 100644 --- a/apps/web/src/i18n/pt-br/sections-editor.ts +++ b/apps/web/src/i18n/pt-br/sections-editor.ts @@ -174,6 +174,8 @@ export const sectionsEditor = { "sectionsEditor.secretField.secretValuePlaceholder": "Valor secreto", "sectionsEditor.secretField.storedSecretMessage": "Um valor secreto está armazenado.", + "sectionsEditor.readOnlyFieldTooltip": + "Produção é somente leitura. Comece um novo rascunho para editar.", "sectionsEditor.sectionList.addSectionButton": "Adicionar seção", "sectionsEditor.sectionList.addVariantMenuItem": "Adicionar variante", "sectionsEditor.sectionList.deleteMenuItem": "Excluir", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 68b77df187..1a3d3e861f 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -8,6 +8,18 @@ export const thread = { "thread.branchPicker.couldntLoadPullRequests": "Não foi possível carregar pull requests do GitHub.", "thread.branchPicker.createBranch": 'Criar "{name}"', + "thread.branchPicker.advanced": "Avançado", + "thread.branchPicker.advancedBack": "Voltar", + "thread.branchPicker.defaultVersionName": "Rascunho", + "thread.branchPicker.delete": "Descartar", + "thread.branchPicker.deleteConfirm": + 'Descartar "{name}"? Isso não pode ser desfeito.', + "thread.branchPicker.live": "Produção", + "thread.branchPicker.moreActions": "Mais ações", + "thread.branchPicker.newVersion": "Novo Rascunho", + "thread.branchPicker.rename": "Renomear", + "thread.branchPicker.save": "Salvar", + "thread.branchPicker.selectVersion": "Selecione uma versão", "thread.branchPicker.newChatHint": "A branch deste chat é fixa. Escolher ou criar uma branch abre um chat novo nela.", "thread.branchPicker.hiddenForkPrs": diff --git a/apps/web/src/i18n/pt-br/virtual-mcp.ts b/apps/web/src/i18n/pt-br/virtual-mcp.ts index ec4a3b57e9..ad6412d98a 100644 --- a/apps/web/src/i18n/pt-br/virtual-mcp.ts +++ b/apps/web/src/i18n/pt-br/virtual-mcp.ts @@ -201,6 +201,9 @@ Defina passo a passo como o agente deve tratar as solicitações. 3. Resumir o resultado e propor próximos passos. 4. Pedir confirmação antes de fazer qualquer alteração. `, + "virtualMcp.virtualMcp.draftsModeTitle": "Modo Rascunhos & Versões", + "virtualMcp.virtualMcp.draftsModeDescription": + "Troca a picker de branch/PR por rascunhos nomeados: switcher de versões, produção somente leitura e publicar para produção.", "virtualMcp.virtualMcp.publishing": "Publicação", "virtualMcp.virtualMcp.publishingDescription": "Controle quando as alterações deste agente podem ser publicadas diretamente, sem revisão por pull request.", diff --git a/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx b/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx new file mode 100644 index 0000000000..b214bc9a73 --- /dev/null +++ b/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx @@ -0,0 +1,56 @@ +import { + Controller, + type Control, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { Label } from "@decocms/ui/components/label.tsx"; +import { Switch } from "@decocms/ui/components/switch.tsx"; +import { useT } from "@/i18n/use-t.ts"; + +/** + * The Draft & Releases mode toggle (`metadata.draftsMode`): gates the drafts UX + * for this code agent — the releases switcher, read-only production, and + * publish-to-production. Off keeps the classic branch/PR picker. + */ +export interface DraftsModeFieldProps { + control: Control; + /** Settings auto-save on change — persist immediately (blur-equivalent). */ + onCommit: () => void; +} + +export function DraftsModeField({ + control, + onCommit, +}: DraftsModeFieldProps) { + const t = useT(); + return ( + } + control={control} + render={({ field }) => ( +
+
+ +

+ {t("virtualMcp.virtualMcp.draftsModeDescription")} +

+
+ { + field.onChange(checked); + onCommit(); + }} + /> +
+ )} + /> + ); +} diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index c137cf73a1..8cbdcefd62 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -85,6 +85,7 @@ import { FieldDescriptionTooltipsField } from "@/components/sandbox/runtime-card import { FastPreviewField } from "@/components/sandbox/runtime-card/fast-preview-field"; import { PublishPolicyField } from "./publish-policy-field"; import { ContentEditingField } from "./content-editing-field"; +import { DraftsModeField } from "./drafts-mode-field"; import { resolveCmsMode } from "@decocms/shared/sdk/types"; type DialogState = { @@ -1095,6 +1096,10 @@ function VirtualMcpDetailViewWithData({ control={form.control} onCommit={flushAndSave} /> + {/* Blocks-form preference — nothing to tune with the CMS off. */} {!cmsOff && ( diff --git a/packages/shared/src/sdk/types/index.ts b/packages/shared/src/sdk/types/index.ts index d04de1dd0d..ea2e4f1890 100644 --- a/packages/shared/src/sdk/types/index.ts +++ b/packages/shared/src/sdk/types/index.ts @@ -41,6 +41,8 @@ export { type SandboxMap, SandboxRecordSchema, type SandboxRecord, + ReleaseSchema, + type Release, type RuntimeMetadata, type RuntimeEnvEntry, type SubmoduleCredential, diff --git a/packages/shared/src/sdk/types/virtual-mcp.ts b/packages/shared/src/sdk/types/virtual-mcp.ts index 4b26a6ae17..9ee7b7e9f5 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -658,6 +658,39 @@ const previewServerUrlMetadataField = z "Preview server URL — the deployment the CMS preview renders against (often the live site, e.g. https://acme.com). Painted in the preview iframe, and the render target for Fast Preview drafts. Supersedes the legacy productionUrl key.", ); +/** + * A named, color-coded release: a working version of the site backed by a git + * branch. `metadata.releases` is a curated, user-managed list — NOT the full + * git branch list — so the switcher shows only versions people named, never + * every branch. The base branch renders as "No ar" and is never stored here; + * the branch stays the content source of truth while name + color are Studio's. + */ +export const ReleaseSchema = z.object({ + branch: z.string().describe("Git branch backing this release"), + name: z.string().describe('User-facing name, e.g. "Black Friday 2026"'), + color: z.string().describe("Dot color token shown in the version switcher"), + createdBy: z.string().optional().describe("User ID who created the release"), + createdAt: z.string().optional().describe("ISO 8601 creation timestamp"), +}); + +export type Release = z.infer; + +const releasesMetadataField = z + .array(ReleaseSchema) + .nullable() + .optional() + .describe( + "Curated list of named, branch-backed releases shown in the version switcher. The base branch ('No ar') is derived, not stored here.", + ); + +const draftsModeMetadataField = z + .boolean() + .nullable() + .optional() + .describe( + "Draft & Releases mode: gates the drafts UX (releases switcher, read-only production, publish-to-production). Off (default) keeps the classic branch/PR picker and post-publish behavior.", + ); + /** * Virtual MCP entity schema - single source of truth * Compliant with collections binding pattern @@ -744,6 +777,8 @@ export const VirtualMCPEntitySchema = z.object({ "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), fastPreview: fastPreviewMetadataField, + releases: releasesMetadataField, + draftsMode: draftsModeMetadataField, }) .loose() .describe("Metadata"), @@ -856,6 +891,8 @@ export const VirtualMCPCreateDataSchema = z.object({ "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), fastPreview: fastPreviewMetadataField, + releases: releasesMetadataField, + draftsMode: draftsModeMetadataField, }) .loose() .superRefine((metadata, ctx) => { @@ -958,6 +995,8 @@ export const VirtualMCPUpdateDataSchema = z.object({ "Blocks form: opt in to showing a field's schema description as a hover tooltip on its title, instead of the default inline text below the title.", ), fastPreview: fastPreviewMetadataField, + releases: releasesMetadataField, + draftsMode: draftsModeMetadataField, }) .loose() .superRefine((metadata, ctx) => { diff --git a/packages/shared/src/tools/registry-metadata.ts b/packages/shared/src/tools/registry-metadata.ts index cac6e06b67..468b5c4487 100644 --- a/packages/shared/src/tools/registry-metadata.ts +++ b/packages/shared/src/tools/registry-metadata.ts @@ -245,6 +245,7 @@ const ALL_TOOL_NAMES = [ "GITHUB_SEARCH_BRANCHES", "GITHUB_PR_STATE", "GITHUB_LAST_PUBLISHED_PR", + "GITHUB_DELETE_BRANCH", // Search tools "GLOBAL_SEARCH", diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 71ceda19bd..574b63c2d8 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -2130,6 +2130,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -2317,6 +2328,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; } | null | undefined; @@ -2489,6 +2511,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -2677,6 +2710,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -2856,6 +2900,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -3008,6 +3063,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; } | null | undefined; @@ -3188,6 +3254,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -3365,6 +3442,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -4399,6 +4487,17 @@ export interface StudioToolIO { productionUrl?: string | null | undefined; fieldDescriptionTooltips?: boolean | null | undefined; fastPreview?: boolean | null | undefined; + releases?: + | { + branch: string; + name: string; + color: string; + createdBy?: string | undefined; + createdAt?: string | undefined; + }[] + | null + | undefined; + draftsMode?: boolean | null | undefined; }; connections: { connection_id: string; @@ -7141,6 +7240,15 @@ export interface StudioToolIO { } | null; }; }; + GITHUB_DELETE_BRANCH: { + input: { + connectionId: string; + owner: string; + repo: string; + branch: string; + }; + output: { deleted: boolean }; + }; GLOBAL_SEARCH: { input: { query: string; From 127fbd4203b8cb99791b4ac889782ccd44548926 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 2 Sep 2026 08:48:25 -0300 Subject: [PATCH 2/5] refactor(drafts): discard keeps the branch, drop GITHUB_DELETE_BRANCH Discarding a draft now only removes the release entry from `metadata.releases`; its git branch is left on GitHub (re-adoptable via Advanced). Removes the now-unused GITHUB_DELETE_BRANCH tool and reverts `resolveGithubConnection` to module-private. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/tools/github/delete-branch.ts | 108 ------------------ apps/api/src/tools/github/graphql.ts | 2 +- apps/api/src/tools/github/index.ts | 1 - apps/api/src/tools/index.ts | 1 - .../components/thread/github/use-releases.ts | 23 +--- .../shared/src/tools/registry-metadata.ts | 1 - packages/shared/src/tools/tool-io.ts | 9 -- 7 files changed, 4 insertions(+), 141 deletions(-) delete mode 100644 apps/api/src/tools/github/delete-branch.ts diff --git a/apps/api/src/tools/github/delete-branch.ts b/apps/api/src/tools/github/delete-branch.ts deleted file mode 100644 index 811061db09..0000000000 --- a/apps/api/src/tools/github/delete-branch.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { z } from "zod"; -import { defineTool } from "../../core/define-tool"; -import { githubConnectionAccessToken } from "@/oauth/github-mint"; -import { RECONNECT_ERROR } from "@/oauth/token-refresh"; -import { resolveGithubConnection } from "./graphql"; - -/** e2e seam: set GITHUB_API_BASE_URL to a local stub (mirrors github-git-data). */ -function githubApiBaseUrl(): string { - return process.env.GITHUB_API_BASE_URL ?? "https://api.github.com"; -} - -const GITHUB_TIMEOUT_MS = 15_000; - -function githubHeaders(token: string): HeadersInit { - return { - Authorization: `token ${token}`, - Accept: "application/vnd.github+json", - "User-Agent": "studio-github", - }; -} - -/** Encode a branch ref segment-by-segment so `feat/x` stays a path, not `%2F`. */ -function encodeRef(branch: string): string { - return branch.split("/").map(encodeURIComponent).join("/"); -} - -/** - * Delete a repository branch (git ref). App-only, connection-scoped. Refuses to - * delete the repository's default branch — that is production ("Produção"), the - * live version people branch off, never a discardable draft. A missing ref is - * treated as already-deleted so the tool is idempotent. - */ -export const GITHUB_DELETE_BRANCH = defineTool({ - name: "GITHUB_DELETE_BRANCH", - description: - "Delete a branch (git ref) from a repository. Refuses to delete the repository's default (production) branch.", - annotations: { - title: "Delete GitHub Branch", - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: true, - }, - _meta: { ui: { visibility: "app" } }, - inputSchema: z.object({ - connectionId: z.string().describe("ID of the mcp-github connection to use"), - owner: z.string().describe("Repository owner (user or org login)"), - repo: z.string().describe("Repository name"), - branch: z - .string() - .describe("Branch name to delete (no `refs/heads/` prefix)"), - }), - outputSchema: z.object({ deleted: z.boolean() }), - handler: async (input, ctx) => { - await ctx.access.check(); - - const branch = input.branch.trim(); - if (!branch) { - throw new Error("Branch name is required"); - } - - const connection = await resolveGithubConnection(ctx, input.connectionId); - const token = await githubConnectionAccessToken(ctx, connection); - if (!token) { - throw new Error(RECONNECT_ERROR); - } - - const repoLabel = `${input.owner}/${input.repo}`; - const base = githubApiBaseUrl(); - - // Never delete the live/default branch — read it fresh, don't trust input. - const repoRes = await fetch(`${base}/repos/${input.owner}/${input.repo}`, { - headers: githubHeaders(token), - signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), - }); - if (!repoRes.ok) { - throw new Error(`Couldn't read ${repoLabel} (${repoRes.status})`); - } - const repoJson = (await repoRes.json()) as { default_branch?: string }; - if (repoJson.default_branch === branch) { - throw new Error( - `Refusing to delete the production branch "${branch}" of ${repoLabel}`, - ); - } - - const delRes = await fetch( - `${base}/repos/${input.owner}/${input.repo}/git/refs/heads/${encodeRef(branch)}`, - { - method: "DELETE", - headers: githubHeaders(token), - signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), - }, - ); - // 204 = deleted; 422/404 = ref already gone — idempotent success. - if ( - delRes.status !== 204 && - delRes.status !== 422 && - delRes.status !== 404 - ) { - const text = await delRes.text().catch(() => ""); - throw new Error( - `Failed to delete branch "${branch}": ${delRes.status} ${text.slice(0, 200)}`, - ); - } - - return { deleted: true }; - }, -}); diff --git a/apps/api/src/tools/github/graphql.ts b/apps/api/src/tools/github/graphql.ts index 40d9066f28..da9540a1c3 100644 --- a/apps/api/src/tools/github/graphql.ts +++ b/apps/api/src/tools/github/graphql.ts @@ -91,7 +91,7 @@ export function unwrapGraphqlData( * caller-supplied, so it is re-read scoped to the authenticated org before its * credential is used — no cross-org token reads (as GITHUB_LIST_USER_ORGS does). */ -export async function resolveGithubConnection( +async function resolveGithubConnection( ctx: StudioContext, connectionId: string, ) { diff --git a/apps/api/src/tools/github/index.ts b/apps/api/src/tools/github/index.ts index 32f69cfd67..a4dca4a302 100644 --- a/apps/api/src/tools/github/index.ts +++ b/apps/api/src/tools/github/index.ts @@ -9,4 +9,3 @@ export { GITHUB_LIST_USER_ORGS } from "./list-user-orgs"; export { GITHUB_SEARCH_BRANCHES } from "./search-branches"; export { GITHUB_PR_STATE } from "./pr-state"; export { GITHUB_LAST_PUBLISHED_PR } from "./last-published-pr"; -export { GITHUB_DELETE_BRANCH } from "./delete-branch"; diff --git a/apps/api/src/tools/index.ts b/apps/api/src/tools/index.ts index a1f4c2b82c..2051facc5a 100644 --- a/apps/api/src/tools/index.ts +++ b/apps/api/src/tools/index.ts @@ -254,7 +254,6 @@ export const CORE_TOOLS = [ GitHubTools.GITHUB_SEARCH_BRANCHES, GitHubTools.GITHUB_PR_STATE, GitHubTools.GITHUB_LAST_PUBLISHED_PR, - GitHubTools.GITHUB_DELETE_BRANCH, // Link tools diff --git a/apps/web/src/components/thread/github/use-releases.ts b/apps/web/src/components/thread/github/use-releases.ts index 0a58b1d172..b5a4327229 100644 --- a/apps/web/src/components/thread/github/use-releases.ts +++ b/apps/web/src/components/thread/github/use-releases.ts @@ -1,7 +1,5 @@ import { useProjectContext, useVirtualMCP, useVirtualMCPActions } from "@/sdk"; import { useQueryClient } from "@tanstack/react-query"; -import { callStudioTool } from "@/lib/studio-tools"; -import { getActiveGithubRepo } from "@/lib/github-repo"; import type { Release, VirtualMCPEntity } from "@decocms/shared/sdk/types"; /** @@ -32,19 +30,13 @@ export function releaseDotClass(color: string | undefined): string { type ItemData = { item: VirtualMCPEntity | null }; -/** - * The curated, branch-backed release list stored at `metadata.releases` — NOT - * the git branch list. Writers patch the cached VIRTUAL_MCP item first so the - * switcher updates instantly, then persist (a failure reverts). Delete also - * removes the git branch so a discarded draft leaves nothing behind. - */ +/** Curated branch-backed release list at `metadata.releases`; discard drops only the entry, leaving the branch on GitHub. */ export function useReleases(virtualMcpId: string) { const vm = useVirtualMCP(virtualMcpId); const actions = useVirtualMCPActions(); const { org } = useProjectContext(); const queryClient = useQueryClient(); const releases: Release[] = vm?.metadata?.releases ?? []; - const repo = getActiveGithubRepo(vm); const isItemQuery = (queryKey: readonly unknown[]) => queryKey[1] === org.id && @@ -87,17 +79,8 @@ export function useReleases(virtualMcpId: string) { const renameRelease = (branch: string, name: string) => write(releases.map((r) => (r.branch === branch ? { ...r, name } : r))); - const deleteRelease = async (branch: string) => { - await write(releases.filter((r) => r.branch !== branch)); - if (repo?.connectionId) { - await callStudioTool(org.slug, "GITHUB_DELETE_BRANCH", { - connectionId: repo.connectionId, - owner: repo.owner, - repo: repo.name, - branch, - }); - } - }; + const deleteRelease = (branch: string) => + write(releases.filter((r) => r.branch !== branch)); return { releases, createRelease, renameRelease, deleteRelease }; } diff --git a/packages/shared/src/tools/registry-metadata.ts b/packages/shared/src/tools/registry-metadata.ts index ab4be5db1d..ce6c8c494f 100644 --- a/packages/shared/src/tools/registry-metadata.ts +++ b/packages/shared/src/tools/registry-metadata.ts @@ -245,7 +245,6 @@ const ALL_TOOL_NAMES = [ "GITHUB_SEARCH_BRANCHES", "GITHUB_PR_STATE", "GITHUB_LAST_PUBLISHED_PR", - "GITHUB_DELETE_BRANCH", // Search tools "GLOBAL_SEARCH", diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 9997cd16b8..3bf468f4de 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -7279,15 +7279,6 @@ export interface StudioToolIO { } | null; }; }; - GITHUB_DELETE_BRANCH: { - input: { - connectionId: string; - owner: string; - repo: string; - branch: string; - }; - output: { deleted: boolean }; - }; GLOBAL_SEARCH: { input: { query: string; From d34ca177ba11c7c1358702c7d362081dff565d5a Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 2 Sep 2026 09:02:16 -0300 Subject: [PATCH 3/5] feat(drafts): Advanced PRs tab + branch name on hover - Advanced picker: Branches/PRs tabs like the classic modal (PRs no longer buried below the branch list). - Switcher rows: drop the selected check (the highlighted row already shows selection) and reveal the real git branch on hover. Co-Authored-By: Claude Opus 4.8 --- .../thread/github/branch-picker.tsx | 243 +++++++++++------- 1 file changed, 153 insertions(+), 90 deletions(-) diff --git a/apps/web/src/components/thread/github/branch-picker.tsx b/apps/web/src/components/thread/github/branch-picker.tsx index 981dbbb861..f5f5739d78 100644 --- a/apps/web/src/components/thread/github/branch-picker.tsx +++ b/apps/web/src/components/thread/github/branch-picker.tsx @@ -24,10 +24,9 @@ import { CommandInput, CommandItem, CommandList, - CommandSeparator, } from "@decocms/ui/components/command.tsx"; +import { Tabs, TabsList, TabsTrigger } from "@decocms/ui/components/tabs.tsx"; import { - Check, ChevronDown, ChevronLeft, ChevronRight, @@ -272,6 +271,7 @@ export function BranchPicker({ baseBranch && pick(baseBranch)} @@ -283,6 +283,7 @@ export function BranchPicker({ value && pick(value)} /> @@ -344,17 +345,19 @@ export function BranchPicker({ function VersionRow({ dot, label, + branch, selected = false, disabled = false, onSelect, }: { dot: string; label: string; + branch?: string | null; selected?: boolean; disabled?: boolean; onSelect: () => void; }) { - return ( + const row = ( ); + if (!branch) return row; + return ( + + {row} + + {branch} + + + ); } /** A named release row: click to switch, with a ⋯ menu to rename or discard. */ @@ -394,22 +405,26 @@ function ReleaseRow({ selected ? "bg-accent" : "hover:bg-accent/60", )} > - + + + + + + {release.branch} + + - (matchesBranchSearch(v, s) ? 1 : 0)}> + (matchesBranchSearch(v, s) ? 1 : 0) + : undefined + } + > + { + setTab(v as "branches" | "prs"); + setSearch(""); + }} + > + + + {t("thread.branchPicker.branchesTab")} + + + {t("thread.branchPicker.prsTab")} + + + { const el = e.currentTarget; - if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) { + if ( + tab === "branches" && + el.scrollHeight - el.scrollTop - el.clientHeight < 48 + ) { fetchMore(); } }} > - {isLoading && ( -
- {t("thread.branchPicker.loadingMore")} -
- )} - {!isLoading && branches.length === 0 && openablePrs.length === 0 && ( - - {t("thread.branchPicker.noBranchesFound")} - - )} - {branches.length > 0 && ( - - {branches.map((b) => ( - onAdopt(b.name, b.name)} - > - - {b.name} - - ))} - - )} - {openablePrs.length > 0 && ( + {tab === "branches" ? ( <> - - - {openablePrs.map((pr) => ( - - onAdopt(pr.head, decodeHtmlEntities(pr.title)) - } + {isLoading && ( +
+ {t("thread.branchPicker.loadingMore")} +
+ )} + {!isLoading && branches.length === 0 && ( + + {t("thread.branchPicker.noBranchesFound")} + + )} + {branches.length > 0 && ( + + {branches.map((b) => ( + onAdopt(b.name, b.name)} + > + + {b.name} + + ))} + + )} + {hasMore && ( +
+ +
+ )} + + ) : ( + <> + {prsLoading && ( +
+ {t("thread.branchPicker.loadingPullRequests")} +
+ )} + {!prsLoading && openablePrs.length === 0 && ( + + {t("thread.branchPicker.noOpenPullRequests")} + + )} + {openablePrs.length > 0 && ( + + {openablePrs.map((pr) => ( + + onAdopt(pr.head, decodeHtmlEntities(pr.title)) + } + > + +
+ + {decodeHtmlEntities(pr.title)} + + + #{pr.number} · {pr.head} + +
+
+ ))} +
+ )} - )} - {hasMore && ( -
- -
)}
From dbacd9f69a0c3b708dd832ec546d067494634fcf Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 2 Sep 2026 09:11:21 -0300 Subject: [PATCH 4/5] feat(drafts): use AlertDialog for discard confirmation Replaces the native window.confirm on draft discard with the design system's AlertDialog (title + description + Cancel / destructive Discard). Co-Authored-By: Claude Opus 4.8 --- .../thread/github/branch-picker.tsx | 64 ++++++++++++++++--- apps/web/src/i18n/en/thread.ts | 2 + apps/web/src/i18n/pt-br/thread.ts | 2 + 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/thread/github/branch-picker.tsx b/apps/web/src/components/thread/github/branch-picker.tsx index f5f5739d78..3d571957ef 100644 --- a/apps/web/src/components/thread/github/branch-picker.tsx +++ b/apps/web/src/components/thread/github/branch-picker.tsx @@ -11,6 +11,16 @@ import { TooltipContent, TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; import { DropdownMenu, DropdownMenuContent, @@ -104,6 +114,7 @@ export function BranchPicker({ const [advanced, setAdvanced] = useState(false); const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(""); + const [pendingDelete, setPendingDelete] = useState(null); const { releases, createRelease, renameRelease, deleteRelease } = useReleases(virtualMcpId); @@ -182,19 +193,17 @@ export function BranchPicker({ setEditName(""); }; - const handleDelete = (r: Release) => { - if ( - !window.confirm(t("thread.branchPicker.deleteConfirm", { name: r.name })) - ) { - return; - } - // Leave the deleted draft for production before it vanishes from the list. + const confirmDelete = () => { + const r = pendingDelete; + setPendingDelete(null); + if (!r) return; + // Land on production before the deleted draft vanishes from the list. if (r.branch === value && baseBranch) pick(baseBranch); else setOpen(false); void deleteRelease(r.branch); }; - return ( + const popover = ( pick(r.branch)} onRename={() => startRename(r)} - onDelete={() => void handleDelete(r)} + onDelete={() => setPendingDelete(r)} /> ), )} @@ -340,6 +349,43 @@ export function BranchPicker({ ); + + return ( + <> + {popover} + { + if (!next) setPendingDelete(null); + }} + > + + + + {t("thread.branchPicker.deleteTitle")} + + + {pendingDelete && + t("thread.branchPicker.deleteConfirm", { + name: pendingDelete.name, + })} + + + + + {t("thread.branchPicker.cancel")} + + + {t("thread.branchPicker.delete")} + + + + + + ); } function VersionRow({ diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index f0be5d5559..bc07d61637 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -8,10 +8,12 @@ export const thread = { "thread.branchPicker.createBranch": 'Create "{name}"', "thread.branchPicker.advanced": "Advanced", "thread.branchPicker.advancedBack": "Back", + "thread.branchPicker.cancel": "Cancel", "thread.branchPicker.defaultVersionName": "Draft", "thread.branchPicker.delete": "Delete", "thread.branchPicker.deleteConfirm": 'Delete "{name}"? This can\'t be undone.', + "thread.branchPicker.deleteTitle": "Discard draft?", "thread.branchPicker.live": "Production", "thread.branchPicker.moreActions": "More actions", "thread.branchPicker.newVersion": "New draft", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 1a3d3e861f..e9d2482d33 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -10,10 +10,12 @@ export const thread = { "thread.branchPicker.createBranch": 'Criar "{name}"', "thread.branchPicker.advanced": "Avançado", "thread.branchPicker.advancedBack": "Voltar", + "thread.branchPicker.cancel": "Cancelar", "thread.branchPicker.defaultVersionName": "Rascunho", "thread.branchPicker.delete": "Descartar", "thread.branchPicker.deleteConfirm": 'Descartar "{name}"? Isso não pode ser desfeito.', + "thread.branchPicker.deleteTitle": "Descartar rascunho?", "thread.branchPicker.live": "Produção", "thread.branchPicker.moreActions": "Mais ações", "thread.branchPicker.newVersion": "Novo Rascunho", From 97ec1e28631b9dc4d4084c6577c1389b84417859 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 2 Sep 2026 09:26:26 -0300 Subject: [PATCH 5/5] feat(drafts): switch to production when enabling Draft & Releases mode Co-Authored-By: Claude Opus 4.8 --- apps/web/src/views/virtual-mcp/drafts-mode-field.tsx | 4 ++++ apps/web/src/views/virtual-mcp/index.tsx | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx b/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx index b214bc9a73..6dfeb11375 100644 --- a/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx +++ b/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx @@ -17,11 +17,14 @@ export interface DraftsModeFieldProps { control: Control; /** Settings auto-save on change — persist immediately (blur-equivalent). */ onCommit: () => void; + /** Fired when the toggle is switched on — the caller lands on production. */ + onEnable?: () => void; } export function DraftsModeField({ control, onCommit, + onEnable, }: DraftsModeFieldProps) { const t = useT(); return ( @@ -47,6 +50,7 @@ export function DraftsModeField({ onCheckedChange={(checked) => { field.onChange(checked); onCommit(); + if (checked) onEnable?.(); }} />
diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index ad085624b5..f363d8a40d 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -2,7 +2,8 @@ import { formatDistanceToNow } from "date-fns"; import { ptBR as ptBRLocale } from "date-fns/locale/pt-BR"; import { generatePrefixedId } from "@decocms/shared/utils/generate-id"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; -import { useChatStream } from "@/components/chat/context"; +import { useChatStream, useOptionalChatTask } from "@/components/chat/context"; +import { useBaseBranch } from "@/components/thread/github/use-version-gate"; import { buildImprovePromptDoc } from "@/components/chat/tiptap/build-improve-prompt-doc"; import { EmptyState } from "@/components/empty-state.tsx"; import { ErrorBoundary } from "@/components/error-boundary"; @@ -370,6 +371,12 @@ function VirtualMcpDetailViewWithData({ const [isImproving, setIsImproving] = useState(false); const { createNewTask, openSidePanel } = usePanelActions(); const { sendMessage } = useChatStream(); + // Enabling Draft & Releases mode lands the thread on production (the base). + const draftsTaskCtx = useOptionalChatTask(); + const draftsBaseBranch = useBaseBranch( + virtualMcp, + draftsTaskCtx?.currentBranch ?? null, + ); const handleImprovePrompt = async () => { if (isImproving) return; @@ -1100,6 +1107,9 @@ function VirtualMcpDetailViewWithData({ + draftsTaskCtx?.setCurrentTaskBranch(draftsBaseBranch) + } /> {/* Blocks-form preference — nothing to tune with the CMS off. */} {!cmsOff && (