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/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..3d571957ef 100644 --- a/apps/web/src/components/thread/github/branch-picker.tsx +++ b/apps/web/src/components/thread/github/branch-picker.tsx @@ -1,92 +1,107 @@ -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 { - Check, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@decocms/ui/components/command.tsx"; +import { Tabs, TabsList, TabsTrigger } from "@decocms/ui/components/tabs.tsx"; +import { 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,107 +111,106 @@ 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 { - 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 [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); - // 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, - ), - ); + 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; - // 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 onListScroll = (event: UIEvent) => { - const target = event.currentTarget; - const distanceFromBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; + const startRename = (r: Release) => { + setEditing(r.branch); + setEditName(r.name); + }; - if (tab === "branches" && distanceFromBottom < 48) { - fetchMore(); - } + const saveRename = (branch: string) => { + const next = editName.trim(); + if (next) void renameRelease(branch, next); + setEditing(null); + setEditName(""); }; - return ( + 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); + }; + + const popover = ( { - // 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 +228,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 +253,461 @@ 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={() => setPendingDelete(r)} + /> + ), + )} + +
+ + + + )} ); -} -type MemberUser = { name?: string | null; image?: string | null }; -type OrgMember = { userId: string; user?: MemberUser }; + 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")} + + + + + + ); +} -/** - * 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, + branch, + selected = false, + disabled = false, + onSelect, }: { - userIds: string[]; - memberById: Map; + dot: string; + label: string; + branch?: string | null; + selected?: boolean; + disabled?: boolean; + onSelect: () => void; }) { - if (userIds.length === 0) return null; - const shown = userIds.slice(0, 3); - const extra = userIds.length - shown.length; + const row = ( + + ); + if (!branch) return row; + return ( + + {row} + + {branch} + + + ); +} +/** 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} - +
+ > + + + + + + {release.branch} + + + + + + + + + + {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 [tab, setTab] = useState<"branches" | "prs">("branches"); + const [search, setSearch] = useState(""); + const { + recent, + yours, + others, + isLoading, + hasMore, + isFetchingMore, + fetchMore, + } = useBranches({ + orgId, + orgSlug, + userId, + connectionId, + sandboxMap, + owner, + repo, + search: tab === "branches" ? search : "", + enabled: enabled && tab === "branches", + }); + const { data: prs = [], isLoading: prsLoading } = useOpenPrs({ + orgId, + orgSlug, + connectionId: connectionId ?? "", + owner, + repo, + enabled: enabled && tab === "prs", + }); + + 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) + : undefined + } + > + + { + setTab(v as "branches" | "prs"); + setSearch(""); + }} + > + + + {t("thread.branchPicker.branchesTab")} + + + {t("thread.branchPicker.prsTab")} + + + + { + const el = e.currentTarget; + if ( + tab === "branches" && + el.scrollHeight - el.scrollTop - el.clientHeight < 48 + ) { + fetchMore(); + } + }} + > + {tab === "branches" ? ( + <> + {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} + +
+
+ ))} +
+ )} + + )} +
+
+
); } 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..b5a4327229 --- /dev/null +++ b/apps/web/src/components/thread/github/use-releases.ts @@ -0,0 +1,86 @@ +import { useProjectContext, useVirtualMCP, useVirtualMCPActions } from "@/sdk"; +import { useQueryClient } from "@tanstack/react-query"; +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 }; + +/** 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 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 = (branch: string) => + write(releases.filter((r) => r.branch !== 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..bc07d61637 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -6,6 +6,20 @@ 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.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", + "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..e9d2482d33 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -8,6 +8,20 @@ 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.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", + "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..6dfeb11375 --- /dev/null +++ b/apps/web/src/views/virtual-mcp/drafts-mode-field.tsx @@ -0,0 +1,60 @@ +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; + /** 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 ( + } + control={control} + render={({ field }) => ( +
+
+ +

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

+
+ { + 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 f4244d68a9..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"; @@ -86,6 +87,7 @@ import { FastPreviewField } from "@/components/sandbox/runtime-card/fast-preview import { InPlaceRenderField } from "@/components/sandbox/runtime-card/in-place-render-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 = { @@ -369,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; @@ -1096,6 +1104,13 @@ function VirtualMcpDetailViewWithData({ control={form.control} onCommit={flushAndSave} /> + + draftsTaskCtx?.setCurrentTaskBranch(draftsBaseBranch) + } + /> {/* 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 aaffd339cc..d159036557 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -676,6 +676,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 @@ -762,6 +795,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, fastPreviewInPlace: fastPreviewInPlaceMetadataField, }) .loose() @@ -875,6 +910,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, fastPreviewInPlace: fastPreviewInPlaceMetadataField, }) .loose() @@ -978,6 +1015,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, fastPreviewInPlace: fastPreviewInPlaceMetadataField, }) .loose() diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 18d669d434..3bf468f4de 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -2160,6 +2160,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -2348,6 +2359,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; fastPreviewInPlace?: boolean | null | undefined; } | null @@ -2521,6 +2543,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -2710,6 +2743,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -2890,6 +2934,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -3043,6 +3098,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; fastPreviewInPlace?: boolean | null | undefined; } | null @@ -3224,6 +3290,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -3402,6 +3479,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: { @@ -4437,6 +4525,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; fastPreviewInPlace?: boolean | null | undefined; }; connections: {