Simplify subtree pause controls

This commit is contained in:
Dotta committed 2026-04-23 09:45:54 -05:00
1 parent 7290dce4df
commit 94c49f97ef
8 files changed
+499 -271

No files matched your search

+31 -1
View File
@@ -325,11 +325,20 @@ type PaperclipWakeBlockerSummary = {
priority: string | null;
};
type PaperclipWakeTreeHoldSummary = {
holdId: string | null;
rootIssueId: string | null;
mode: string | null;
reason: string | null;
};
type PaperclipWakePayload = {
reason: string | null;
issue: PaperclipWakeIssue | null;
checkedOutByHarness: boolean;
dependencyBlockedInteraction: boolean;
treeHoldInteraction: boolean;
activeTreeHold: PaperclipWakeTreeHoldSummary | null;
unresolvedBlockerIssueIds: string[];
unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[];
executionStage: PaperclipWakeExecutionStage | null;
@@ -435,6 +444,16 @@ function normalizePaperclipWakeBlockerSummary(value: unknown): PaperclipWakeBloc
return { id, identifier, title, status, priority };
}
function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTreeHoldSummary | null {
const hold = parseObject(value);
const holdId = asString(hold.holdId, "").trim() || null;
const rootIssueId = asString(hold.rootIssueId, "").trim() || null;
const mode = asString(hold.mode, "").trim() || null;
const reason = asString(hold.reason, "").trim() || null;
if (!holdId && !rootIssueId && !mode && !reason) return null;
return { holdId, rootIssueId, mode, reason };
}
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
const principal = parseObject(value);
const typeRaw = asString(principal.type, "").trim().toLowerCase();
@@ -511,7 +530,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
.filter((entry): entry is PaperclipWakeBlockerSummary => Boolean(entry))
: [];
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold);
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
@@ -520,6 +540,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
issue: normalizePaperclipWakeIssue(payload.issue),
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false),
treeHoldInteraction: asBoolean(payload.treeHoldInteraction, false),
activeTreeHold,
unresolvedBlockerIssueIds,
unresolvedBlockerSummaries,
executionStage,
@@ -614,6 +636,14 @@ export function renderPaperclipWakePrompt(
lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`);
}
}
if (normalized.treeHoldInteraction) {
lines.push("- tree-hold interaction: yes");
lines.push("- execution scope: respond or triage the human comment; the subtree remains paused until an explicit resume action");
if (normalized.activeTreeHold) {
const hold = normalized.activeTreeHold;
lines.push(`- active tree hold: ${hold.holdId ?? "unknown"}${hold.rootIssueId ? ` rooted at ${hold.rootIssueId}` : ""}${hold.mode ? ` (${hold.mode})` : ""}`);
}
}
if (normalized.missingCount > 0) {
lines.push(`- omitted comments: ${normalized.missingCount}`);
}
@@ -346,7 +346,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
});
it("suppresses descendant wakeups while allowing root course-correction comment wakes under a pause hold", async () => {
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
@@ -424,31 +424,32 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.then((rows) => rows[0] ?? null);
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
const rootCommentWake = await heartbeat.wakeup(agentId, {
const childCommentWake = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: rootIssueId, commentId: randomUUID() },
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
payload: { issueId: childIssueId, commentId: randomUUID() },
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented" },
});
expect(rootCommentWake).not.toBeNull();
const rootRun = await db
expect(childCommentWake).not.toBeNull();
const childRun = await db
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, rootCommentWake!.id))
.where(eq(heartbeatRuns.id, childCommentWake!.id))
.then((rows) => rows[0] ?? null);
expect(rootRun?.contextSnapshot).toMatchObject({
expect(childRun?.contextSnapshot).toMatchObject({
treeHoldInteraction: true,
activeTreeHold: {
holdId: hold.id,
rootIssueId,
mode: "pause",
courseCorrectionOnly: true,
interaction: true,
},
});
});
it("suppresses root comment wakes when the active hold is full_pause", async () => {
it("allows comment interaction wakes when a legacy hold has a full_pause note", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
@@ -500,15 +501,19 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
});
expect(rootCommentWake).toBeNull();
const skippedWake = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
})
.from(agentWakeupRequests)
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${rootIssueId}`)
expect(rootCommentWake).not.toBeNull();
const rootRun = await db
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, rootCommentWake!.id))
.then((rows) => rows[0] ?? null);
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
expect(rootRun?.contextSnapshot).toMatchObject({
treeHoldInteraction: true,
activeTreeHold: {
rootIssueId,
mode: "pause",
interaction: true,
},
});
});
});
@@ -333,12 +333,13 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
});
});
it("blocks held descendant checkout but allows root comment course-correction checkout", async () => {
it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const rootRunId = randomUUID();
const childRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -376,15 +377,26 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
assigneeAgentId: agentId,
},
]);
await db.insert(heartbeatRuns).values({
id: rootRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
});
await db.insert(heartbeatRuns).values([
{
id: rootRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented", commentId: randomUUID() },
},
{
id: childRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented", commentId: randomUUID() },
},
]);
const treeSvc = issueTreeControlService(db);
await treeSvc.createHold(companyId, rootIssueId, {
@@ -402,6 +414,10 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
}),
});
const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId);
expect(checkedOutChild.status).toBe("in_progress");
expect(checkedOutChild.checkoutRunId).toBe(childRunId);
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
expect(checkedOutRoot.status).toBe("in_progress");
expect(checkedOutRoot.checkoutRunId).toBe(rootRunId);
@@ -429,12 +445,8 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
await expect(issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId)).rejects.toMatchObject({
status: 409,
details: expect.objectContaining({
rootIssueId,
mode: "pause",
}),
});
const checkedOutLegacyFullPauseRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
});
});
+18 -32
View File
@@ -80,7 +80,7 @@ import {
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import { issueTreeControlService, isCourseCorrectionPauseReleasePolicy } from "./issue-tree-control.js";
import { issueTreeControlService } from "./issue-tree-control.js";
import {
getIssueContinuationSummaryDocument,
refreshIssueContinuationSummary,
@@ -168,11 +168,6 @@ function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallb
return "fresh_session_safer_invocation";
}
const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]);
const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([
"issue_commented",
"issue_reopened_via_comment",
"issue_comment_mentioned",
]);
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@@ -1257,17 +1252,17 @@ function shouldRequireIssueCommentForWake(
);
}
const BLOCKED_INTERACTION_WAKE_REASONS = new Set([
const ISSUE_INTERACTION_WAKE_REASONS = new Set([
"issue_commented",
"issue_reopened_via_comment",
"issue_comment_mentioned",
]);
function allowsBlockedIssueInteractionWake(
function allowsIssueInteractionWake(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (!wakeReason || !BLOCKED_INTERACTION_WAKE_REASONS.has(wakeReason)) return false;
if (!wakeReason || !ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason)) return false;
return Boolean(deriveCommentId(contextSnapshot, null));
}
@@ -1636,6 +1631,8 @@ async function buildPaperclipWakePayload(input: {
: null,
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold),
unresolvedBlockerIssueIds: Array.isArray(input.contextSnapshot.unresolvedBlockerIssueIds)
? input.contextSnapshot.unresolvedBlockerIssueIds.filter((value): value is string => typeof value === "string" && value.length > 0)
: [],
@@ -3507,12 +3504,8 @@ export function heartbeatService(db: Db) {
const issueId = readNonEmptyString(context.issueId);
if (issueId) {
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(run.companyId, issueId);
const wakeReason = readNonEmptyString(context.wakeReason) ?? readNonEmptyString(context.reason);
const isRootCourseCorrectionWake =
activePauseHold?.isRoot === true &&
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
if (activePauseHold && !isRootCourseCorrectionWake) {
const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(context);
if (activePauseHold && !treeHoldInteractionWake) {
await cancelRunInternal(run.id, "Cancelled because issue is held by an active subtree pause hold");
await logActivity(db, {
companyId: run.companyId,
@@ -3536,7 +3529,7 @@ export function heartbeatService(db: Db) {
const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]);
const unresolvedBlockerCount = dependencyReadiness.get(issueId)?.unresolvedBlockerCount ?? 0;
if (unresolvedBlockerCount > 0 && !allowsBlockedIssueInteractionWake(context)) {
if (unresolvedBlockerCount > 0 && !allowsIssueInteractionWake(context)) {
logger.debug({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: skipping blocked run");
return null;
}
@@ -6119,12 +6112,8 @@ export function heartbeatService(db: Db) {
const deferredPayload = parseObject(deferred.payload);
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
const deferredWakeReason = readNonEmptyString(deferredContextSeed.wakeReason) ?? readNonEmptyString(deferred.reason);
const canPromoteRootCourseCorrection =
activePauseHold?.isRoot === true &&
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
Boolean(deferredWakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(deferredWakeReason));
if (activePauseHold && !canPromoteRootCourseCorrection) {
const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(deferredContextSeed);
if (activePauseHold && !treeHoldInteractionWake) {
await tx
.update(agentWakeupRequests)
.set({
@@ -6139,14 +6128,14 @@ export function heartbeatService(db: Db) {
const promotedContextSeed: Record<string, unknown> = { ...deferredContextSeed };
if (activePauseHold) {
const courseCorrectionOnly = isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy);
promotedContextSeed.treeHoldInteraction = true;
promotedContextSeed.activeTreeHold = {
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
reason: activePauseHold.reason,
releasePolicy: activePauseHold.releasePolicy,
courseCorrectionOnly,
interaction: true,
};
}
const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
@@ -6540,13 +6529,9 @@ export function heartbeatService(db: Db) {
if (issueId) {
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(agent.companyId, issueId);
if (activePauseHold) {
const wakeReason = readNonEmptyString(enrichedContextSnapshot.wakeReason) ?? reason;
const isRootCourseCorrectionWake =
activePauseHold.isRoot &&
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
const treeHoldInteractionWake = allowsIssueInteractionWake(enrichedContextSnapshot);
if (!isRootCourseCorrectionWake) {
if (!treeHoldInteractionWake) {
await writeSkippedRequest("issue_tree_hold_active");
await logActivity(db, {
companyId: agent.companyId,
@@ -6569,13 +6554,14 @@ export function heartbeatService(db: Db) {
return null;
}
enrichedContextSnapshot.treeHoldInteraction = true;
enrichedContextSnapshot.activeTreeHold = {
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
reason: activePauseHold.reason,
releasePolicy: activePauseHold.releasePolicy,
courseCorrectionOnly: isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy),
interaction: true,
};
}
}
@@ -6697,7 +6683,7 @@ export function heartbeatService(db: Db) {
const blockedInteractionWake =
dependencyReadiness &&
!dependencyReadiness.isDependencyReady &&
allowsBlockedIssueInteractionWake(enrichedContextSnapshot);
allowsIssueInteractionWake(enrichedContextSnapshot);
if (blockedInteractionWake) {
enrichedContextSnapshot.dependencyBlockedInteraction = true;
@@ -77,13 +77,6 @@ function normalizeReleasePolicy(
return releasePolicy ?? DEFAULT_RELEASE_POLICY;
}
export function isCourseCorrectionPauseReleasePolicy(
releasePolicy: IssueTreeHoldReleasePolicy | null | undefined,
) {
const note = typeof releasePolicy?.note === "string" ? releasePolicy.note.trim().toLowerCase() : "";
return note !== "full_pause";
}
function coerceIssueStatus(status: string): IssueStatus {
return ISSUE_STATUSES.includes(status as IssueStatus) ? (status as IssueStatus) : "backlog";
}
+22 -7
View File
@@ -38,7 +38,6 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getDefaultCompanyGoal } from "./goals.js";
import {
issueTreeControlService,
isCourseCorrectionPauseReleasePolicy,
type ActiveIssueTreePauseHoldGate,
} from "./issue-tree-control.js";
@@ -50,7 +49,7 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([
const ISSUE_INTERACTION_WAKE_REASONS = new Set([
"issue_commented",
"issue_reopened_via_comment",
"issue_comment_mentioned",
@@ -87,6 +86,18 @@ function readStringFromRecord(record: unknown, key: string) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readLatestWakeCommentId(record: unknown) {
if (!record || typeof record !== "object") return null;
const value = (record as Record<string, unknown>).wakeCommentIds;
if (Array.isArray(value)) {
const latest = value
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.at(-1);
if (latest) return latest.trim();
}
return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId");
}
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
@@ -941,12 +952,12 @@ export function issueService(db: Db) {
}
}
async function isRootCourseCorrectionCheckoutAllowed(
async function isTreeHoldInteractionCheckoutAllowed(
companyId: string,
checkoutRunId: string | null,
gate: ActiveIssueTreePauseHoldGate,
_gate: ActiveIssueTreePauseHoldGate,
) {
if (!gate.isRoot || !checkoutRunId || !isCourseCorrectionPauseReleasePolicy(gate.releasePolicy)) return false;
if (!checkoutRunId) return false;
const run = await db
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
@@ -955,7 +966,11 @@ export function issueService(db: Db) {
const wakeReason =
readStringFromRecord(run?.contextSnapshot, "wakeReason") ??
readStringFromRecord(run?.contextSnapshot, "reason");
return Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
return Boolean(
wakeReason &&
ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason) &&
readLatestWakeCommentId(run?.contextSnapshot),
);
}
async function assertAssignableUser(companyId: string, userId: string) {
@@ -2228,7 +2243,7 @@ export function issueService(db: Db) {
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id);
if (
activePauseHold &&
!(await isRootCourseCorrectionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold))
!(await isTreeHoldInteractionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold))
) {
throw conflict("Issue checkout blocked by active subtree pause hold", {
issueId: id,
+288 -11
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared";
import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared";
import { act, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -273,11 +273,13 @@ vi.mock("@/components/ui/popover", () => ({
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
DialogContent: ({ children, className }: { children?: ReactNode; className?: string }) => (
<div data-slot="dialog-content" className={className}>{children}</div>
),
DialogDescription: ({ children, className }: { children?: ReactNode; className?: string }) => <p className={className}>{children}</p>,
DialogFooter: ({ children, className }: { children?: ReactNode; className?: string }) => <div className={className}>{children}</div>,
DialogHeader: ({ children, className }: { children?: ReactNode; className?: string }) => <div className={className}>{children}</div>,
DialogTitle: ({ children, className }: { children?: ReactNode; className?: string }) => <h2 className={className}>{children}</h2>,
}));
vi.mock("@/components/ui/sheet", () => ({
@@ -366,6 +368,34 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
} as Issue;
}
function createAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: "Software Engineer",
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
...overrides,
};
}
function createPauseHold(overrides: Partial<IssueTreeHold> = {}): IssueTreeHold {
const now = new Date("2026-04-21T00:00:00.000Z");
return {
@@ -444,7 +474,7 @@ function createResumePreview(): IssueTreeControlPreview {
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
affectedAgents: 1,
},
countsByStatus: { todo: 2 },
issues: [
@@ -455,7 +485,7 @@ function createResumePreview(): IssueTreeControlPreview {
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
@@ -470,7 +500,7 @@ function createResumePreview(): IssueTreeControlPreview {
status: "todo",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
@@ -481,6 +511,61 @@ function createResumePreview(): IssueTreeControlPreview {
],
skippedIssues: [],
activeRuns: [],
affectedAgents: [{ agentId: "agent-1", issueCount: 2, activeRunCount: 0 }],
warnings: [],
};
}
function createPausePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "pause",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 2,
affectedIssues: 2,
skippedIssues: 0,
activeRuns: 1,
queuedRuns: 0,
affectedAgents: 0,
},
countsByStatus: { todo: 2 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: false,
skipReason: null,
},
{
id: "child-1",
identifier: "PAP-2",
title: "Paused child",
status: "in_review",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: false,
skipReason: null,
},
],
skippedIssues: [],
activeRuns: [],
affectedAgents: [],
warnings: [],
};
@@ -557,6 +642,46 @@ function createRestorePreview(): IssueTreeControlPreview {
};
}
function createCancelPreview(issueCount = 8): IssueTreeControlPreview {
const issues = Array.from({ length: issueCount }, (_, index) => ({
id: index === 0 ? "issue-1" : `child-${index}`,
identifier: index === 0 ? "PAP-1" : `PAP-${index + 1}`,
title: index === 0 ? "Issue detail smoke" : `Cancellable child ${index}`,
status: "todo" as const,
parentId: index === 0 ? null : "issue-1",
depth: index === 0 ? 0 : 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "cancel" as const,
skipped: false,
skipReason: null,
}));
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "cancel",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: issueCount,
affectedIssues: issueCount,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
},
countsByStatus: { todo: issueCount },
issues,
skippedIssues: [],
activeRuns: [],
affectedAgents: [],
warnings: [],
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
@@ -702,6 +827,7 @@ describe("IssueDetail", () => {
);
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockIssuesApi.releaseTreeHold.mockImplementation(() => {
activePauseHoldState = null;
return Promise.resolve(releasedHold);
@@ -739,6 +865,7 @@ describe("IssueDetail", () => {
.filter((button) => button.textContent?.trim() === "Resume subtree")
.at(-1);
expect(applyResumeButton).toBeTruthy();
expect(container.textContent).toContain("CodexCoder");
await act(async () => {
applyResumeButton!.click();
@@ -748,7 +875,7 @@ describe("IssueDetail", () => {
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
reason: null,
metadata: { wakeAgents: false },
metadata: { wakeAgents: true },
});
expect(mockIssuesApi.getTreeControlState.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
@@ -761,6 +888,88 @@ describe("IssueDetail", () => {
});
});
it("uses simplified full-subtree pause controls", async () => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Paused child",
});
const pausePreview = createPausePreview();
const pauseHold = createPauseHold({
id: "pause-hold-1",
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
members: [],
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await flushReact();
const pauseMenuButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Pause subtree...");
expect(pauseMenuButton).toBeTruthy();
await act(async () => {
pauseMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).not.toContain("Pause mode");
expect(container.textContent).not.toContain("Release policy");
expect(container.textContent).not.toContain("Status breakdown");
expect(container.textContent).not.toContain("Active runs cancelled");
expect(container.textContent).toContain("Paused child");
const pauseApplyButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Pause and stop work");
expect(pauseApplyButton).toBeTruthy();
await act(async () => {
pauseApplyButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
});
});
it("exposes restore subtree from the issue actions menu", async () => {
const childIssue = createIssue({
id: "child-1",
@@ -835,7 +1044,7 @@ describe("IssueDetail", () => {
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).toContain("Restore issues cancelled by this subtree operation so work can resume.");
expect(container.textContent).toContain("Issues restored: 1");
expect(container.textContent).toContain("Cancelled child");
const restoreApplyButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Restore 1 issues");
@@ -853,4 +1062,72 @@ describe("IssueDetail", () => {
metadata: { wakeAgents: false },
});
});
it("bounds the subtree control dialog with an internal scroll body", async () => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Cancellable child",
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(createCancelPreview(24));
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const cancelMenuButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Cancel subtree...");
expect(cancelMenuButton).toBeTruthy();
await act(async () => {
cancelMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "cancel",
releasePolicy: { strategy: "manual" },
});
const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLDivElement | null;
expect(dialogContent).toBeTruthy();
expect(dialogContent!.className).toContain("max-h-[calc(100dvh-2rem)]");
expect(dialogContent!.className).toContain("overflow-hidden");
expect(dialogContent!.className).toContain("flex-col");
const bodyScrollRegion = Array.from(dialogContent!.querySelectorAll("div"))
.find((element) =>
typeof element.className === "string"
&& element.className.includes("overflow-y-auto")
&& element.textContent?.includes("Reason (required)"),
);
expect(bodyScrollRegion?.className).toContain("min-h-0");
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
const footer = Array.from(dialogContent!.querySelectorAll("div"))
.find((element) =>
typeof element.className === "string"
&& element.className.includes("border-t")
&& element.textContent?.includes("Close"),
);
expect(footer?.className).toContain("bg-background");
});
});
+87 -177
View File
@@ -65,6 +65,7 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
import { IssueProperties } from "../components/IssueProperties";
@@ -157,33 +158,11 @@ const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
restore: "Restore subtree",
};
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
pause: "Pause active execution in this issue subtree and hold descendant execution until release.",
pause: "Pause active execution in this issue subtree until an explicit resume.",
resume: "Release the active subtree pause hold so held work can continue.",
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
restore: "Restore issues cancelled by this subtree operation so work can resume.",
};
const TREE_CONTROL_STRATEGY_OPTIONS = [
{ value: "manual", label: "Manual release" },
{ value: "after_active_runs_finish", label: "Release after active runs finish" },
] as const;
const TREE_CONTROL_PAUSE_VARIANTS = [
{
value: "course_correction",
label: "Course-correction pause",
description: "Hold descendants while the root can still run for planner follow-up.",
},
{
value: "full_pause",
label: "Full subtree pause",
description: "Stop root and descendant execution until an explicit resume action.",
},
] as const;
type TreeControlPauseVariant = (typeof TREE_CONTROL_PAUSE_VARIANTS)[number]["value"];
function parsePauseVariantFromNote(note: string | null | undefined): TreeControlPauseVariant {
const normalized = note?.trim().toLowerCase();
return normalized === "course_correction" ? "course_correction" : "full_pause";
}
function treeControlPreviewErrorCopy(error: unknown): string {
if (error instanceof ApiError) {
@@ -1048,12 +1027,8 @@ export function IssueDetail() {
const [treeControlOpen, setTreeControlOpen] = useState(false);
const [treeControlMode, setTreeControlMode] = useState<IssueTreeControlMode>("pause");
const [treeControlReason, setTreeControlReason] = useState("");
const [treeControlPauseVariant, setTreeControlPauseVariant] = useState<TreeControlPauseVariant>("course_correction");
const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false);
const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = useState(false);
const [treeControlReleaseStrategy, setTreeControlReleaseStrategy] = useState<"manual" | "after_active_runs_finish">(
"manual",
);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
@@ -1247,14 +1222,12 @@ export function IssueDetail() {
"tree-control-preview",
issueId ?? "pending",
treeControlMode,
treeControlReleaseStrategy,
treeControlPauseVariant,
],
queryFn: () =>
issuesApi.previewTreeControl(issueId!, {
mode: treeControlMode,
releasePolicy: {
strategy: treeControlReleaseStrategy,
strategy: "manual",
},
}),
enabled: treeControlOpen && !!issueId && canManageTreeControl,
@@ -1543,10 +1516,8 @@ export function IssueDetail() {
mode: treeControlMode,
reason: treeControlReason.trim() || null,
releasePolicy: {
strategy: treeControlReleaseStrategy,
...(treeControlMode === "pause"
? { note: treeControlPauseVariant }
: {}),
strategy: "manual",
...(treeControlMode === "pause" ? { note: "full_pause" } : {}),
},
...(treeControlMode === "restore"
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
@@ -1557,7 +1528,6 @@ export function IssueDetail() {
onSuccess: async (result) => {
const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode];
const cancelCount = result.preview?.totals.activeRuns ?? 0;
const pauseModeText = treeControlPauseVariant === "course_correction" ? "Course-correction pause enabled." : "Full subtree pause enabled.";
pushToast({
title: result.kind === "release"
? "Subtree resumed"
@@ -1567,10 +1537,10 @@ export function IssueDetail() {
body: result.kind === "release"
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
: result.hold.mode === "pause"
? `${pauseModeText} ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: result.hold.reason?.trim()
? result.hold.reason
: `Control applied with ${result.hold.releasePolicy?.strategy ?? "manual"} release policy.`,
: "Subtree control applied.",
});
setTreeControlOpen(false);
setTreeControlReason("");
@@ -2671,26 +2641,13 @@ export function IssueDetail() {
const hasAttachments = attachmentList.length > 0;
const treePreviewWarnings = treeControlPreview?.warnings ?? [];
const activePauseVariant = parsePauseVariantFromNote(
activeRootPauseHold?.releasePolicy?.note ?? activePauseHold?.releasePolicy?.note,
);
const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length
?? Math.max(heldIssueIds.size - 1, 0);
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
const previewTerminalSkippedCount = treeControlPreview?.skippedIssues.filter((candidate) => candidate.skipReason === "terminal_status").length ?? 0;
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
const previewAffectedDescendantCount = treePreviewAffectedIssues.filter((candidate) => candidate.depth > 0).length;
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
const treePreviewPrimaryCountLabel =
treeControlMode === "pause"
? "Issues held"
: treeControlMode === "cancel"
? "Issues cancelled"
: treeControlMode === "restore"
? "Issues restored"
: "Issues released";
const treeControlPrimaryButtonLabel =
treeControlMode === "pause"
? "Pause and stop work"
@@ -2699,27 +2656,39 @@ export function IssueDetail() {
: treeControlMode === "restore"
? `Restore ${previewAffectedIssueCount} issues`
: "Resume subtree";
const deferredComposerHint = activePauseHold && !activePauseHold.isRoot
? "Will be delivered when the subtree resumes."
: null;
const rootCourseCorrectionComposerHint = activePauseHold?.isRoot && activePauseVariant === "course_correction"
const treePreviewAffectedIssueRows = treePreviewAffectedIssues.map((candidate) => ({
candidate,
issue: {
...issue,
id: candidate.id,
identifier: candidate.identifier,
title: candidate.title,
status: candidate.status,
parentId: candidate.parentId,
assigneeAgentId: candidate.assigneeAgentId,
assigneeUserId: candidate.assigneeUserId,
executionRunId: candidate.activeRun?.id ?? null,
} satisfies Issue,
}));
const treePreviewAffectedAgentRows = (treeControlPreview?.affectedAgents ?? [])
.map((previewAgent) => ({
...previewAgent,
agent: agentMap.get(previewAgent.agentId) ?? null,
}))
.sort((a, b) => (a.agent?.name ?? a.agentId).localeCompare(b.agent?.name ?? b.agentId));
const pausedComposerHint = activePauseHold
? (
issue.assigneeAgentId
? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} in course-correction mode. Descendants stay held.`
: "Assign a planner above to run course corrections."
? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.`
: "Assign an agent to wake them for triage while the subtree remains paused."
)
: null;
const composerHint = rootCourseCorrectionComposerHint ?? deferredComposerHint;
const queuedCommentReason: "hold" | "active_run" | "other" =
activePauseHold && (!activePauseHold.isRoot || activePauseVariant === "full_pause")
? "hold"
: "active_run";
const composerHint = pausedComposerHint;
const queuedCommentReason: "hold" | "active_run" | "other" = "active_run";
const canApplyTreeControl =
Boolean(treeControlPreview)
&& !treeControlPreviewLoading
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed))
&& (treeControlMode !== "resume" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0)
&& (treeControlMode !== "restore" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0);
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed));
const attachmentUploadButton = (
<>
<input
@@ -2792,9 +2761,7 @@ export function IssueDetail() {
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">Subtree pause is active.</span>
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
{activePauseVariant === "course_correction"
? "Course-correction mode keeps descendants held while root planning can continue."
: "Full pause mode holds the full subtree until resume."}
Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
</span>
</div>
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
@@ -2807,7 +2774,7 @@ export function IssueDetail() {
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(false);
setTreeControlWakeAgentsOnResume(true);
setTreeControlOpen(true);
}}
>
@@ -2818,6 +2785,7 @@ export function IssueDetail() {
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(true);
setTreeControlOpen(true);
}}
>
@@ -3017,7 +2985,7 @@ export function IssueDetail() {
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(false);
setTreeControlWakeAgentsOnResume(true);
setTreeControlOpen(true);
setMoreOpen(false);
}}
@@ -3448,46 +3416,20 @@ export function IssueDetail() {
</Tabs>
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
<DialogTitle>{TREE_CONTROL_MODE_LABEL[treeControlMode]}</DialogTitle>
<DialogDescription>
{TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
{treeControlMode === "cancel" ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
</div>
) : null}
{treeControlMode === "pause" ? (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Pause mode</p>
{TREE_CONTROL_PAUSE_VARIANTS.map((variant) => (
<label
key={variant.value}
className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border p-2 text-sm",
treeControlPauseVariant === variant.value ? "border-primary bg-primary/5" : "border-border",
)}
>
<input
type="radio"
className="mt-0.5"
checked={treeControlPauseVariant === variant.value}
onChange={() => setTreeControlPauseVariant(variant.value)}
/>
<span>
<span className="block font-medium">{variant.label}</span>
<span className="text-xs text-muted-foreground">{variant.description}</span>
</span>
</label>
))}
</div>
) : null}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{treeControlMode === "cancel" ? "Reason (required)" : "Reason (optional)"}
@@ -3501,41 +3443,36 @@ export function IssueDetail() {
</div>
{(treeControlMode === "resume" || treeControlMode === "restore") ? (
<label className="flex items-start gap-2 rounded-md border border-border bg-muted/20 p-2 text-sm">
<input
type="checkbox"
className="mt-0.5"
disabled={previewAffectedAgentCount === 0}
checked={treeControlWakeAgentsOnResume}
onChange={(event) => setTreeControlWakeAgentsOnResume(event.target.checked)}
/>
<span>
<span className="block font-medium">Wake affected agents ({previewAffectedAgentCount})</span>
<span className="text-xs text-muted-foreground">
{previewAffectedAgentCount === 0
? "No assigned agents are eligible to wake from this preview."
: treeControlMode === "restore"
? "Unchecked by default. Restore does not wake agents unless explicitly enabled."
: "Unchecked by default. Resume does not wake agents unless explicitly enabled."}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5"
disabled={previewAffectedAgentCount === 0}
checked={treeControlWakeAgentsOnResume}
onChange={(event) => setTreeControlWakeAgentsOnResume(event.target.checked)}
/>
<span>
<span className="block font-medium">Wake affected agents ({previewAffectedAgentCount})</span>
<span className="text-xs text-muted-foreground">
{previewAffectedAgentCount === 0
? "No assigned agents are eligible to wake from this preview."
: "Wake assigned agents after this operation completes."}
</span>
</span>
</span>
</label>
) : null}
{(treeControlMode === "pause" || treeControlMode === "resume") ? (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Release policy</label>
<select
value={treeControlReleaseStrategy}
onChange={(event) => setTreeControlReleaseStrategy(event.target.value as "manual" | "after_active_runs_finish")}
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
>
{TREE_CONTROL_STRATEGY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{treeControlWakeAgentsOnResume && treePreviewAffectedAgentRows.length > 0 ? (
<div className="max-h-32 space-y-1 overflow-y-auto overscroll-contain">
{treePreviewAffectedAgentRows.map(({ agentId, agent }) => (
<div key={agentId} className="flex items-center gap-2 rounded-sm px-1 py-1 text-sm hover:bg-accent/50">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background">
<AgentIcon icon={agent?.icon} className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 truncate">{agent?.name ?? agentId.slice(0, 8)}</span>
</div>
))}
</div>
) : null}
</div>
) : null}
@@ -3551,7 +3488,7 @@ export function IssueDetail() {
</label>
) : null}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="space-y-2">
{treeControlPreviewLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
@@ -3573,17 +3510,7 @@ export function IssueDetail() {
</Button>
</div>
) : treeControlPreview ? (
<div className="mt-2 space-y-2">
<div className="grid grid-cols-1 gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<span>{treePreviewPrimaryCountLabel}: {previewAffectedIssueCount}</span>
<span>Descendants affected: {previewAffectedDescendantCount}</span>
<span>Active runs cancelled: {treeControlPreview.totals.activeRuns}</span>
<span>Queued runs dropped: {treeControlPreview.totals.queuedRuns}</span>
<span>Agents affected: {treeControlPreview.totals.affectedAgents}</span>
{previewTerminalSkippedCount > 0 ? (
<span>Terminal issues skipped: {previewTerminalSkippedCount}</span>
) : null}
</div>
<div className="space-y-2">
{treePreviewWarnings.length > 0 ? (
<div className="space-y-1">
{treePreviewWarnings.map((warning) => (
@@ -3593,38 +3520,21 @@ export function IssueDetail() {
))}
</div>
) : null}
{treeControlPreview.countsByStatus ? (
<details className="rounded border border-border/60 bg-background p-2 text-xs text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">Status breakdown</summary>
<div className="mt-2 grid grid-cols-2 gap-1">
{Object.entries(treeControlPreview.countsByStatus).map(([status, count]) => (
<span key={status}>{status.replaceAll("_", " ")}: {count ?? 0}</span>
))}
</div>
</details>
) : null}
{treeControlPreview.affectedAgents.length > 0 ? (
<details className="rounded border border-border/60 bg-background p-2 text-xs text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">Affected agents ({treeControlPreview.affectedAgents.length})</summary>
<div className="mt-2 space-y-1">
{treeControlPreview.affectedAgents.map((agent) => (
<div key={agent.agentId} className="flex items-center justify-between gap-2">
<span>{agentMap.get(agent.agentId)?.name ?? agent.agentId.slice(0, 8)}</span>
<span>{agent.issueCount} issues · {agent.activeRunCount} active runs</span>
</div>
))}
</div>
</details>
) : null}
{treePreviewAffectedIssues.length > 0 ? (
<div className="max-h-40 space-y-1 overflow-y-auto rounded border border-border/70 bg-background p-2">
{treePreviewAffectedIssues.slice(0, 20).map((candidate) => (
<div key={candidate.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
</span>
<span className="text-muted-foreground">{candidate.status}</span>
{treePreviewAffectedIssueRows.length > 0 ? (
<div className="max-h-56 overflow-y-auto overscroll-contain">
{treePreviewAffectedIssueRows.map(({ candidate, issue: previewIssue }) => (
<div key={candidate.id} style={candidate.depth > 0 ? { paddingLeft: `${Math.min(candidate.depth, 6) * 14}px` } : undefined}>
<Link
to={createIssueDetailPath(candidate.identifier ?? candidate.id)}
issuePrefetch={previewIssue}
className="group flex items-start gap-2 border-b border-border py-2 pl-1 pr-2 text-sm no-underline text-inherit transition-colors last:border-b-0 hover:bg-accent/50 sm:items-center"
>
<StatusIcon status={candidate.status} />
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{candidate.identifier ?? candidate.id.slice(0, 8)}
</span>
<span className="min-w-0 flex-1 truncate">{candidate.title}</span>
</Link>
</div>
))}
</div>
@@ -3635,7 +3545,7 @@ export function IssueDetail() {
)}
</div>
</div>
<DialogFooter>
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
<Button variant="outline" onClick={() => setTreeControlOpen(false)} disabled={executeTreeControl.isPending}>
Close
</Button>