feat(drafts): Draft & Releases mode behind a per-agent flag - #6851
feat(drafts): Draft & Releases mode behind a per-agent flag#6851guitavano wants to merge 2 commits into
Conversation
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 <noreply@anthropic.com>
…anches-drafts # Conflicts: # packages/shared/src/sdk/types/virtual-mcp.ts # packages/shared/src/tools/tool-io.ts
There was a problem hiding this comment.
20 issues found and verified against the latest diff
You’re at about 97% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/src/components/sections-editor/fields/read-only-pane.tsx">
<violation number="1" location="apps/web/src/components/sections-editor/fields/read-only-pane.tsx:45">
P2: In read-only production, `inert` removes the entire field subtree from the accessibility tree, so screen-reader users cannot read values or labels, and the tooltip cannot be reached from the keyboard. Keep the value content exposed while disabling mutation controls through their read-only/disabled semantics, and provide a focusable wrapper if the explanation must be available to keyboard users.</violation>
</file>
<file name="apps/web/src/components/thread/github/start-draft-cta.tsx">
<violation number="1" location="apps/web/src/components/thread/github/start-draft-cta.tsx:26">
P2: Rapidly clicking this CTA can create multiple drafts before the first mutation finishes, and stale `releases` snapshots can overwrite one another. Disable the CTA or guard the creation handler until `createDraft()` settles.</violation>
</file>
<file name="apps/web/src/components/sandbox/blocks/blocks-panel.tsx">
<violation number="1" location="apps/web/src/components/sandbox/blocks/blocks-panel.tsx:109">
P1: When drafts mode shows a production loader, the JSON editor still edits and autosaves the production branch because `RunnableBlockEditor` does not consume this read-only context. Pass read-only into that editor and disable its JSON editing/save path as well.</violation>
</file>
<file name="apps/web/src/components/thread/github/use-version-gate.ts">
<violation number="1" location="apps/web/src/components/thread/github/use-version-gate.ts:75">
P2: When a user creates drafts from the production CTA, every draft is named `Draft`/`Rascunho`, so the Releases switcher cannot distinguish them. Generate the next numbered draft name here, matching the branch picker's `nextDraftName` behavior.</violation>
</file>
<file name="apps/web/src/components/sections-editor/section-list.tsx">
<violation number="1" location="apps/web/src/components/sections-editor/section-list.tsx:640">
P1: In read-only mode, when the section list is empty the empty-state Add section button remains enabled (`disabled={!canAddSection}`) and calls onAddSection, breaking the read-only guarantee. Apply the same `readOnly ||` gate there that was added to the main Add section button.</violation>
</file>
<file name="apps/web/src/components/sections-editor/schema-form.tsx">
<violation number="1" location="apps/web/src/components/sections-editor/schema-form.tsx:163">
P1: Production primitive-array items and direct `renderField` callers remain editable because `ro` checks `props.readOnly`, but those callers rely on `ReadOnlyContext` and omit the prop. Pass the context state into every direct `renderField` call, including `ArrayField` and `GeneralSeoForm`, or make the renderer consume the context.</violation>
</file>
<file name="packages/shared/src/tools/registry-metadata.ts">
<violation number="1" location="packages/shared/src/tools/registry-metadata.ts:248">
P1: When a regular member discards a draft, `GITHUB_DELETE_BRANCH` fails authorization because this addition does not grant it through basic usage or any selectable capability. Add its `MANAGEMENT_TOOLS` metadata, mark the destructive action appropriately, and include it in the intended permission capability so discovery and draft discard work.</violation>
</file>
<file name="apps/api/src/tools/github/delete-branch.ts">
<violation number="1" location="apps/api/src/tools/github/delete-branch.ts:94">
P1: When GitHub rejects deletion with 422, this condition reports the branch as already deleted. Preserve 422 failures, or accept them only when the response explicitly says the reference is missing, so failed discards do not silently leave branches behind.</violation>
</file>
<file name="apps/web/src/components/thread/github/cms-header-actions.tsx">
<violation number="1" location="apps/web/src/components/thread/github/cms-header-actions.tsx:246">
P2: When the thread branch update fails during draft-mode publish completion, this `await` resolves immediately and the next line deletes the merged branch anyway. Return and await the underlying `setBranch` operation before deleting the draft, so a failed switch cannot leave the thread pointing at a deleted branch.</violation>
</file>
<file name="apps/web/src/components/thread/github/branch-picker.tsx">
<violation number="1" location="apps/web/src/components/thread/github/branch-picker.tsx:160">
P2: When release metadata persistence fails, this ignored promise still switches to the newly minted branch. Catch the failure and reconcile the branch/release state so the user does not get an unlisted, undiscardable branch.</violation>
<violation number="2" location="apps/web/src/components/thread/github/branch-picker.tsx:181">
P2: When a rename fails, `saveRename` immediately closes the editor, so the entered name disappears and the user cannot retry it. Keep the editor open until `renameRelease` succeeds.
(Based on your team's feedback about keeping edits open on save failure.)</violation>
<violation number="3" location="apps/web/src/components/thread/github/branch-picker.tsx:473">
P2: When the Advanced branch request fails, this view reports an empty repository instead of the GitHub error. Render `isError` separately with the existing load-failure message and a retry path.</violation>
<violation number="4" location="apps/web/src/components/thread/github/branch-picker.tsx:550">
P2: When Advanced lists the repository base branch, this handler adopts it as a release and creates a draft entry for read-only production. Pass `baseBranch` into `AdvancedPicker` and exclude it before calling `onAdopt`.</violation>
</file>
<file name="apps/web/src/components/chat/input.tsx">
<violation number="1" location="apps/web/src/components/chat/input.tsx:643">
P2: When a web thread is pinned to an unsupported coding harness, this production branch returns the draft CTA before `hostedRuntimeBlocked` is checked. Check the hosted-runtime guard before offering the CTA so coding-agent threads cannot bypass the desktop-only restriction.</violation>
</file>
<file name="apps/web/src/components/sandbox/content/content-browser.tsx">
<violation number="1" location="apps/web/src/components/sandbox/content/content-browser.tsx:1186">
P1: When production is selected, the sidebar and dialogs remain outside this provider, so create, rename, delete, duplicate, and JSON-save actions can still mutate production. Gate `ItemList` and every mutation dialog/handler, and make right-pane structural controls honor `useIsReadOnly` before enabling this mode.</violation>
</file>
<file name="apps/web/src/components/thread/github/branch-picker-legacy.tsx">
<violation number="1" location="apps/web/src/components/thread/github/branch-picker-legacy.tsx:303">
P2: When all returned pull requests come from forks, the picker shows “No open pull requests” even though it found fork PRs and reports them below. Suppress the empty state when `hiddenForkPrs > 0` so the message remains accurate.</violation>
<violation number="2" location="apps/web/src/components/thread/github/branch-picker-legacy.tsx:324">
P2: When a pull request title contains an out-of-range numeric HTML entity, `decodeHtmlEntities(pr.title)` throws and can break the picker render. Validate the code point range or make the decoder leave invalid entities unchanged before rendering GitHub titles.</violation>
</file>
<file name="apps/web/src/components/thread/github/header-actions.tsx">
<violation number="1" location="apps/web/src/components/thread/github/header-actions.tsx:340">
P2: When the task branch is temporarily null or differs from the live sandbox branch, publishing merges `githubHeadBranch` but cleanup captures `branch`. Use the actual merged branch for `deleteRelease` so the merged draft is discarded and an unrelated branch is not removed.</violation>
</file>
<file name="apps/web/src/components/thread/github/use-releases.ts">
<violation number="1" location="apps/web/src/components/thread/github/use-releases.ts:85">
P2: When two release writes overlap, each mutation sends a full array from a stale `releases` snapshot, so the last server update can silently discard the other change. Serialize release writes or compute each update from the latest cache/server state before persisting.</violation>
<violation number="2" location="apps/web/src/components/thread/github/use-releases.ts:93">
P2: When branch deletion fails, `deleteRelease` has already removed the release from metadata, so discard leaves an orphaned GitHub branch and loses the UI entry needed to retry. Roll back the metadata write when `GITHUB_DELETE_BRANCH` fails, and surface the original failure.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| // On production the editor stays visible but read-only per widget. | ||
| const gateReadOnly = (children: ReactNode) => ( | ||
| <div data-testid="blocks-panel" className="h-full min-h-0 overflow-hidden"> | ||
| <ReadOnlyPane readOnly={readOnly} className="h-full min-h-0"> |
There was a problem hiding this comment.
P1: When drafts mode shows a production loader, the JSON editor still edits and autosaves the production branch because RunnableBlockEditor does not consume this read-only context. Pass read-only into that editor and disable its JSON editing/save path as well.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/sandbox/blocks/blocks-panel.tsx, line 109:
<comment>When drafts mode shows a production loader, the JSON editor still edits and autosaves the production branch because `RunnableBlockEditor` does not consume this read-only context. Pass read-only into that editor and disable its JSON editing/save path as well.</comment>
<file context>
@@ -95,28 +103,32 @@ export function BlocksPanel({
+ // On production the editor stays visible but read-only per widget.
+ const gateReadOnly = (children: ReactNode) => (
+ <div data-testid="blocks-panel" className="h-full min-h-0 overflow-hidden">
+ <ReadOnlyPane readOnly={readOnly} className="h-full min-h-0">
+ {children}
+ </ReadOnlyPane>
</file context>
| size="sm" | ||
| className="mt-2 w-full" | ||
| disabled={!canAddSection} | ||
| disabled={readOnly || !canAddSection} |
There was a problem hiding this comment.
P1: In read-only mode, when the section list is empty the empty-state Add section button remains enabled (disabled={!canAddSection}) and calls onAddSection, breaking the read-only guarantee. Apply the same readOnly || gate there that was added to the main Add section button.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/sections-editor/section-list.tsx, line 640:
<comment>In read-only mode, when the section list is empty the empty-state Add section button remains enabled (`disabled={!canAddSection}`) and calls onAddSection, breaking the read-only guarantee. Apply the same `readOnly ||` gate there that was added to the main Add section button.</comment>
<file context>
@@ -632,7 +637,7 @@ export function SectionList({
size="sm"
className="mt-2 w-full"
- disabled={!canAddSection}
+ disabled={readOnly || !canAddSection}
onClick={onAddSection}
>
</file context>
|
|
||
| // Read-only: leaf value widgets get a read-only wrap; containers stay navigable. | ||
| const ro = (node: ReactNode): ReactNode => | ||
| props.readOnly ? ( |
There was a problem hiding this comment.
P1: Production primitive-array items and direct renderField callers remain editable because ro checks props.readOnly, but those callers rely on ReadOnlyContext and omit the prop. Pass the context state into every direct renderField call, including ArrayField and GeneralSeoForm, or make the renderer consume the context.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/sections-editor/schema-form.tsx, line 163:
<comment>Production primitive-array items and direct `renderField` callers remain editable because `ro` checks `props.readOnly`, but those callers rely on `ReadOnlyContext` and omit the prop. Pass the context state into every direct `renderField` call, including `ArrayField` and `GeneralSeoForm`, or make the renderer consume the context.</comment>
<file context>
@@ -156,6 +158,14 @@ function renderResolvedBlockRefValue(props: FieldProps): ReactNode | null {
+ // Read-only: leaf value widgets get a read-only wrap; containers stay navigable.
+ const ro = (node: ReactNode): ReactNode =>
+ props.readOnly ? (
+ <ReadOnlyFieldWrap key={props.path}>{node}</ReadOnlyFieldWrap>
+ ) : (
</file context>
| "GITHUB_SEARCH_BRANCHES", | ||
| "GITHUB_PR_STATE", | ||
| "GITHUB_LAST_PUBLISHED_PR", | ||
| "GITHUB_DELETE_BRANCH", |
There was a problem hiding this comment.
P1: When a regular member discards a draft, GITHUB_DELETE_BRANCH fails authorization because this addition does not grant it through basic usage or any selectable capability. Add its MANAGEMENT_TOOLS metadata, mark the destructive action appropriately, and include it in the intended permission capability so discovery and draft discard work.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/shared/src/tools/registry-metadata.ts, line 248:
<comment>When a regular member discards a draft, `GITHUB_DELETE_BRANCH` fails authorization because this addition does not grant it through basic usage or any selectable capability. Add its `MANAGEMENT_TOOLS` metadata, mark the destructive action appropriately, and include it in the intended permission capability so discovery and draft discard work.</comment>
<file context>
@@ -245,6 +245,7 @@ const ALL_TOOL_NAMES = [
"GITHUB_SEARCH_BRANCHES",
"GITHUB_PR_STATE",
"GITHUB_LAST_PUBLISHED_PR",
+ "GITHUB_DELETE_BRANCH",
// Search tools
</file context>
| signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), | ||
| }, | ||
| ); | ||
| // 204 = deleted; 422/404 = ref already gone — idempotent success. |
There was a problem hiding this comment.
P1: When GitHub rejects deletion with 422, this condition reports the branch as already deleted. Preserve 422 failures, or accept them only when the response explicitly says the reference is missing, so failed discards do not silently leave branches behind.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/tools/github/delete-branch.ts, line 94:
<comment>When GitHub rejects deletion with 422, this condition reports the branch as already deleted. Preserve 422 failures, or accept them only when the response explicitly says the reference is missing, so failed discards do not silently leave branches behind.</comment>
<file context>
@@ -0,0 +1,108 @@
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
+ },
+ );
+ // 204 = deleted; 422/404 = ref already gone — idempotent success.
+ if (
+ delRes.status !== 204 &&
</file context>
| {t("thread.branchPicker.loadingPullRequests")} | ||
| </div> | ||
| )} | ||
| {!prsError && !prsLoading && ( |
There was a problem hiding this comment.
P2: When all returned pull requests come from forks, the picker shows “No open pull requests” even though it found fork PRs and reports them below. Suppress the empty state when hiddenForkPrs > 0 so the message remains accurate.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/thread/github/branch-picker-legacy.tsx, line 303:
<comment>When all returned pull requests come from forks, the picker shows “No open pull requests” even though it found fork PRs and reports them below. Suppress the empty state when `hiddenForkPrs > 0` so the message remains accurate.</comment>
<file context>
@@ -0,0 +1,506 @@
+ {t("thread.branchPicker.loadingPullRequests")}
+ </div>
+ )}
+ {!prsError && !prsLoading && (
+ <CommandEmpty>
+ {search.trim()
</file context>
| {!prsError && !prsLoading && ( | |
| {!prsError && !prsLoading && hiddenForkPrs === 0 && ( |
| <GitPullRequest className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" /> | ||
| <div className="flex min-w-0 flex-1 flex-col"> | ||
| <span className="truncate"> | ||
| {decodeHtmlEntities(pr.title)} |
There was a problem hiding this comment.
P2: When a pull request title contains an out-of-range numeric HTML entity, decodeHtmlEntities(pr.title) throws and can break the picker render. Validate the code point range or make the decoder leave invalid entities unchanged before rendering GitHub titles.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/thread/github/branch-picker-legacy.tsx, line 324:
<comment>When a pull request title contains an out-of-range numeric HTML entity, `decodeHtmlEntities(pr.title)` throws and can break the picker render. Validate the code point range or make the decoder leave invalid entities unchanged before rendering GitHub titles.</comment>
<file context>
@@ -0,0 +1,506 @@
+ <GitPullRequest className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
+ <div className="flex min-w-0 flex-1 flex-col">
+ <span className="truncate">
+ {decodeHtmlEntities(pr.title)}
+ </span>
+ <span className="truncate text-xs text-muted-foreground">
</file context>
|
|
||
| const switchToFreshBranch = async () => { | ||
| if (draftsModeEnabled(vm)) { | ||
| const published = branch; |
There was a problem hiding this comment.
P2: When the task branch is temporarily null or differs from the live sandbox branch, publishing merges githubHeadBranch but cleanup captures branch. Use the actual merged branch for deleteRelease so the merged draft is discarded and an unrelated branch is not removed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/thread/github/header-actions.tsx, line 340:
<comment>When the task branch is temporarily null or differs from the live sandbox branch, publishing merges `githubHeadBranch` but cleanup captures `branch`. Use the actual merged branch for `deleteRelease` so the merged draft is discarded and an unrelated branch is not removed.</comment>
<file context>
@@ -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) {
</file context>
| const published = branch; | |
| const published = githubHeadBranch; |
| }); | ||
| }; | ||
|
|
||
| const createRelease = (release: Release) => write([...releases, release]); |
There was a problem hiding this comment.
P2: When two release writes overlap, each mutation sends a full array from a stale releases snapshot, so the last server update can silently discard the other change. Serialize release writes or compute each update from the latest cache/server state before persisting.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/thread/github/use-releases.ts, line 85:
<comment>When two release writes overlap, each mutation sends a full array from a stale `releases` snapshot, so the last server update can silently discard the other change. Serialize release writes or compute each update from the latest cache/server state before persisting.</comment>
<file context>
@@ -0,0 +1,103 @@
+ });
+ };
+
+ const createRelease = (release: Release) => write([...releases, release]);
+
+ const renameRelease = (branch: string, name: string) =>
</file context>
| const deleteRelease = async (branch: string) => { | ||
| await write(releases.filter((r) => r.branch !== branch)); | ||
| if (repo?.connectionId) { | ||
| await callStudioTool(org.slug, "GITHUB_DELETE_BRANCH", { |
There was a problem hiding this comment.
P2: When branch deletion fails, deleteRelease has already removed the release from metadata, so discard leaves an orphaned GitHub branch and loses the UI entry needed to retry. Roll back the metadata write when GITHUB_DELETE_BRANCH fails, and surface the original failure.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/thread/github/use-releases.ts, line 93:
<comment>When branch deletion fails, `deleteRelease` has already removed the release from metadata, so discard leaves an orphaned GitHub branch and loses the UI entry needed to retry. Roll back the metadata write when `GITHUB_DELETE_BRANCH` fails, and surface the original failure.</comment>
<file context>
@@ -0,0 +1,103 @@
+ 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,
</file context>
Summary
Adds an opt-in Draft & Releases mode for code agents — a per-agent flag (
metadata.draftsMode, default off) that replaces the classic branch/PR picker with a named-drafts UX. When off, everything behaves exactly as before.When enabled:
cursor-not-allowed, with a hover tooltip "Produção é somente leitura. Comece um novo rascunho para editar." Structural mutations (add/delete/reorder sections, array items, page name/path) are disabled too.Backend: new
GITHUB_DELETE_BRANCHtool (connection-scoped, refuses to delete the repo's default/production branch, idempotent);releases+draftsModeadded to the VirtualMCP metadata schema; tool contracts regenerated.Safety — off by default
With the flag off, the switcher routes to the restored classic branch/PR picker (
branch-picker-legacy.tsx, faithful tomain), read-only/publish behaviors fall back to classic, and no displayed string changes. The new i18n keys and theGITHUB_DELETE_BRANCHtool / metadata fields are dormant until the flag is on. The only non-gated addition is the settings toggle itself (shown, off).Also included
max_connectionsto 500 (ensure-services.ts) to avoid a boot-time "too many clients" retry storm. Dev-only.Testing
bun run check(web/api/shared tsc),bun run knip,bun run fmt,bun run lint— clean.chat-mode-row.test.tsx) assertingdraftsModeselects the releases switcher vs the classic picker.Follow-ups
🤖 Generated with Claude Code
Summary by cubic
Adds an opt-in Draft & Releases mode for code agents, gated by a per-agent
metadata.draftsModeflag that defaults to off. When off, the classic branch/PR picker and post-publish behavior stay exactly as they are.When enabled
GITHUB_DELETE_BRANCHtool that refuses to delete the default branch.Rollout
max_connectionsto 500 to avoid a boot-time retry storm.Written for commit b9dc277. Summary will update on new commits.