From c0b0257334aba8f233763d458703d07cb6fa886d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 8 Jun 2026 21:11:12 +0000 Subject: [PATCH] Phase 3B follow-up: assignee picker interrupt clarity + pause affects summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the remaining two surfaces of the PAP-10668 design spec on top of the shared helpers/views shipped in PAP-10669 (PAP-10675). Surface 2 — standalone assignee picker (IssueProperties): - Grouped "Agents" / "Board users" sections plus the "No assignee" reset. - A live role=status banner (" is running — changing the assignee will interrupt this run") while a run is in flight. - An "Interrupt & assign" confirm step: picking a different target mid-run stages the selection until the operator confirms, instead of applying it silently. hasActiveRun is threaded from IssueDetail's resolvedHasActiveRun. Surface 4 — pause/hold dialog (IssueDetail): - A "What this affects" bucket summary (live agent runs / queued wakes / agent-owned / human-owned / static) computed from the tree-control preview, plus an explanatory "Nothing live to pause" line replacing the bare warning. Shared: - lib/interrupt-handoff.ts: describeReassignInterrupt + computePauseAffectsSummary (pure, disjoint-bucket partition). - InterruptHandoffViews.tsx: AssigneeRunningBanner, InterruptAssignConfirm, PauseAffectsSummaryView presentational views. - Storybook "Surfaces/Interrupt Handoff": AssigneePicker + PauseAffects stories. Tests: new logic (8) + view components (4) + an IssueProperties integration test for the banner/confirm gate. Focused suites green; tsc -b clean. Co-Authored-By: Claude Opus 4.8 --- ui/src/components/IssueProperties.test.tsx | 67 +++++ ui/src/components/IssueProperties.tsx | 274 ++++++++++++------ .../InterruptHandoffViews.test.tsx | 70 ++++- .../InterruptHandoffViews.tsx | 126 +++++++- ui/src/lib/interrupt-handoff.test.ts | 68 +++++ ui/src/lib/interrupt-handoff.ts | 124 ++++++++ ui/src/pages/IssueDetail.tsx | 13 + .../stories/interrupt-handoff.stories.tsx | 76 ++++- 8 files changed, 733 insertions(+), 85 deletions(-) diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 4ee639326bd..e7b72d14d6a 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -385,6 +385,73 @@ describe("IssueProperties", () => { document.body.innerHTML = ""; }); + it("groups the assignee picker and gates a live-run reassign behind an interrupt confirm", async () => { + const minimalAgent = (id: string, name: string) => + ({ + id, + name, + role: "", + title: null, + icon: null, + status: "active", + orgChainHealth: { status: "ok" }, + } as unknown as Parameters[0][number]); + mockAgentsApi.list.mockResolvedValue([minimalAgent("agent-1", "ClaudeCoder"), minimalAgent("agent-2", "QA")]); + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ assigneeAgentId: "agent-1" }), + childIssues: [], + onUpdate, + inline: true, + hasActiveRun: true, + }); + await flush(); + + // Wait for the agents query to resolve so the current assignee renders. + let trigger: HTMLButtonElement | undefined; + await waitForAssertion(() => { + trigger = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("ClaudeCoder"), + ); + expect(trigger).toBeTruthy(); + }); + await act(async () => { + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + // Live-run banner + grouped section headers are present. + expect(container.querySelector("[data-testid='assignee-running-banner']")?.textContent).toContain( + "ClaudeCoder is running", + ); + expect(container.textContent).toContain("Agents"); + expect(container.textContent).toContain("Board users"); + + // Picking a different agent mid-run stages a confirm rather than applying. + const qaOption = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "QA", + ); + expect(qaOption).toBeTruthy(); + await act(async () => { + qaOption!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(container.querySelector("[data-testid='interrupt-assign-confirm']")).not.toBeNull(); + expect(onUpdate).not.toHaveBeenCalled(); + + // Confirming applies the reassignment. + const confirmBtn = container.querySelector( + "[data-testid='interrupt-assign-confirm-action']", + )!; + await act(async () => { + confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(onUpdate).toHaveBeenCalledWith({ assigneeAgentId: "agent-2", assigneeUserId: null }); + + act(() => root.unmount()); + }); + it("always exposes the add sub-issue action", async () => { const onAddSubIssue = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 02f3d1298b6..b730bd83ce7 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -16,7 +16,6 @@ import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue- import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, - getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser, @@ -52,6 +51,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; +import { + AssigneeRunningBanner, + InterruptAssignConfirm, + type HandoffChipResolvers, +} from "./interrupt-handoff/InterruptHandoffViews"; +import { describeReassignInterrupt } from "../lib/interrupt-handoff"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); @@ -143,6 +148,9 @@ interface IssuePropertiesProps { onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; + /** Whether an agent run is currently in flight on this issue, so the assignee + * picker can warn that reassigning will interrupt it. */ + hasActiveRun?: boolean; } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; @@ -381,12 +389,21 @@ export function IssueProperties({ onAddSubIssue, onUpdate, inline, + hasActiveRun = false, }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(""); + /** When a run is live, a selection is staged here until the operator confirms + * the interrupt rather than applying it immediately. */ + const [pendingAssignee, setPendingAssignee] = useState<{ + assigneeAgentId: string | null; + assigneeUserId: string | null; + label: string; + track?: () => void; + } | null>(null); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); @@ -543,15 +560,10 @@ export function IssueProperties({ }; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); - const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), [agents, recentAssigneeIds], ); - const recentAssigneeValues = useMemo( - () => recentAssigneeSelectionIds, - [recentAssigneeSelectionIds], - ); const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), @@ -788,6 +800,51 @@ export function IssueProperties({ : issue.assigneeUserId ? `user:${issue.assigneeUserId}` : ""; + + // --- Interrupt-handoff clarity for the assignee picker (design surface 2) --- + const handoffResolvers: HandoffChipResolvers = useMemo( + () => ({ + agentMap: new Map((agents ?? []).map((agent) => [agent.id, { name: agent.name, icon: agent.icon }])), + resolveUserLabel: (id) => userLabel(id), + }), + // userLabel closes over userLabelMap + currentUserId, both reflected here. + [agents, userLabelMap, currentUserId], + ); + const reassignInterruptCopy = useMemo( + () => describeReassignInterrupt({ runningAgentName: assignee?.name ?? null }), + [assignee?.name], + ); + const closeAssigneePicker = () => { + setAssigneeOpen(false); + setAssigneeSearch(""); + setPendingAssignee(null); + }; + const applyAssignee = (next: { assigneeAgentId: string | null; assigneeUserId: string | null }, track?: () => void) => { + track?.(); + onUpdate(next); + closeAssigneePicker(); + }; + /** Apply a selection immediately, or stage it for confirmation while a run is live. */ + const selectAssignee = ( + next: { assigneeAgentId: string | null; assigneeUserId: string | null }, + label: string, + track?: () => void, + ) => { + const nextValue = next.assigneeAgentId + ? `agent:${next.assigneeAgentId}` + : next.assigneeUserId + ? `user:${next.assigneeUserId}` + : ""; + if (nextValue === selectedAssigneeValue) { + closeAssigneePicker(); + return; + } + if (hasActiveRun) { + setPendingAssignee({ ...next, label, track }); + return; + } + applyAssignee(next, track); + }; const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { onUpdate({ executionPolicy: buildExecutionPolicy({ @@ -1287,48 +1344,121 @@ export function IssueProperties({ ); - const assigneePickerOptions = orderItemsBySelectedAndRecent( - [ - { id: "", kind: "none" as const, label: "No assignee", searchText: "" }, - ...(currentUserId - ? [{ - id: `user:${currentUserId}`, - kind: "user" as const, - userId: currentUserId, - label: "Assign to me", - searchText: userLabel(currentUserId) ?? "", - }] - : []), - ...(issue.createdByUserId && issue.createdByUserId !== currentUserId - ? [{ - id: `user:${issue.createdByUserId}`, - kind: "user" as const, - userId: issue.createdByUserId, - label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", - searchText: creatorUserLabel ?? "requester", - }] - : []), - ...otherUserOptions.map((option) => ({ - id: option.id, - kind: "user" as const, - userId: option.id.slice("user:".length), - label: option.label, - searchText: option.searchText ?? "", - })), - ...sortedAgents.map((agent) => ({ - id: `agent:${agent.id}`, - kind: "agent" as const, - agent, - label: agent.name, - searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, - })), - ], - selectedAssigneeValue, - recentAssigneeValues, + // Grouped picker options (design surface 2): a board-users section and an + // agents section, plus the "No assignee" reset. Agents stay recency-sorted + // within their group via `sortedAgents`. + const userAssigneeOptions = [ + ...(currentUserId + ? [{ + kind: "user" as const, + value: `user:${currentUserId}`, + userId: currentUserId, + label: "Assign to me", + searchText: userLabel(currentUserId) ?? "", + }] + : []), + ...(issue.createdByUserId && issue.createdByUserId !== currentUserId + ? [{ + kind: "user" as const, + value: `user:${issue.createdByUserId}`, + userId: issue.createdByUserId, + label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", + searchText: creatorUserLabel ?? "requester", + }] + : []), + ...otherUserOptions.map((option) => ({ + kind: "user" as const, + value: option.id, + userId: option.id.slice("user:".length), + label: option.label, + searchText: option.searchText ?? "", + })), + ]; + const agentAssigneeOptions = sortedAgents.map((agent) => ({ + kind: "agent" as const, + value: `agent:${agent.id}`, + agent, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })); + + const matchesAssigneeSearch = (label: string, searchText: string) => { + if (!assigneeSearch.trim()) return true; + return `${label} ${searchText}`.toLowerCase().includes(assigneeSearch.toLowerCase()); + }; + + type AssigneeOptionLike = + | { kind: "none"; value: string; label: string; searchText: string } + | { kind: "user"; value: string; userId: string; label: string; searchText: string } + | { kind: "agent"; value: string; agent: (typeof agentAssigneeOptions)[number]["agent"]; label: string; searchText: string }; + + const renderAssigneeOption = (option: AssigneeOptionLike) => ( + ); - const assigneeContent = ( + const visibleUserOptions = userAssigneeOptions.filter((option) => + matchesAssigneeSearch(option.label, option.searchText), + ); + const visibleAgentOptions = agentAssigneeOptions.filter((option) => + matchesAssigneeSearch(option.label, option.searchText), + ); + const sectionHeader = (text: string) => ( +
+ {text} +
+ ); + + const assigneeContent = pendingAssignee ? ( +
+ + applyAssignee( + { assigneeAgentId: pendingAssignee.assigneeAgentId, assigneeUserId: pendingAssignee.assigneeUserId }, + pendingAssignee.track, + ) + } + onCancel={() => setPendingAssignee(null)} + /> +
+ ) : ( <> + {hasActiveRun ? ( +
+ +
+ ) : null} setAssigneeSearch(e.target.value)} autoFocus={!inline} /> -
- {assigneePickerOptions - .filter((option) => { - if (!assigneeSearch.trim()) return true; - const q = assigneeSearch.toLowerCase(); - return `${option.label} ${option.searchText}`.toLowerCase().includes(q); - }) - .map((option) => ( - - ))} +
+ {renderAssigneeOption({ kind: "none", value: "", label: "No assignee", searchText: "" })} + {visibleAgentOptions.length > 0 ? ( + <> + {sectionHeader("Agents")} + {visibleAgentOptions.map((option) => renderAssigneeOption(option))} + + ) : null} + {visibleUserOptions.length > 0 ? ( + <> + {sectionHeader("Board users")} + {visibleUserOptions.map((option) => renderAssigneeOption(option))} + + ) : null} + {visibleAgentOptions.length === 0 && visibleUserOptions.length === 0 ? ( +
No matches.
+ ) : null}
); @@ -1789,7 +1901,7 @@ export function IssueProperties({ inline={inline} label="Assignee" open={assigneeOpen} - onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }} + onOpenChange={(open) => { setAssigneeOpen(open); if (!open) { setAssigneeSearch(""); setPendingAssignee(null); } }} triggerContent={assigneeTrigger} popoverClassName="w-52" extra={issue.assigneeAgentId ? ( diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx index 6e0056f01a9..9c9fd660b63 100644 --- a/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx +++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx @@ -6,13 +6,20 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AssigneeChip, + AssigneeRunningBanner, ComposerHandoffPreviewRow, ComposerMentionCoach, HandoffWakeRow, + InterruptAssignConfirm, + PauseAffectsSummaryView, RunStatusBadge, type HandoffChipResolvers, } from "./InterruptHandoffViews"; -import { computeComposerHandoffPreview } from "../../lib/interrupt-handoff"; +import { + computeComposerHandoffPreview, + computePauseAffectsSummary, + describeReassignInterrupt, +} from "../../lib/interrupt-handoff"; const resolvers: HandoffChipResolvers = { agentMap: new Map([ @@ -180,3 +187,64 @@ describe("ComposerMentionCoach", () => { expect(onDismiss).toHaveBeenCalledOnce(); }); }); + +describe("AssigneeRunningBanner", () => { + it("announces the interrupt as a status with the running agent name", () => { + const host = mount( + , + ); + const banner = host.querySelector("[data-testid='assignee-running-banner']")!; + expect(banner.getAttribute("role")).toBe("status"); + expect(banner.textContent).toContain("ClaudeCoder is running"); + }); +}); + +describe("InterruptAssignConfirm", () => { + it("renders the target chip and wires confirm/cancel", () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const host = mount( + , + ); + expect(host.querySelector("[data-testid='handoff-assignee-chip']")?.textContent).toContain("QA"); + const confirmBtn = host.querySelector("[data-testid='interrupt-assign-confirm-action']")!; + act(() => confirmBtn.click()); + expect(onConfirm).toHaveBeenCalledOnce(); + const cancelBtn = Array.from(host.querySelectorAll("button")).find( + (b) => b.textContent === "Cancel", + )!; + act(() => cancelBtn.click()); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); + +describe("PauseAffectsSummaryView", () => { + it("renders only non-zero buckets with counts", () => { + const summary = computePauseAffectsSummary([ + { assigneeAgentId: "a1", assigneeUserId: null, activeRun: { status: "running" } }, + { assigneeAgentId: null, assigneeUserId: "u1", activeRun: null }, + ]); + const host = mount(); + expect(host.querySelector("[data-bucket='live_runs']")?.textContent).toContain("1"); + expect(host.querySelector("[data-bucket='human_owned']")?.textContent).toContain("1"); + // Empty buckets are hidden. + expect(host.querySelector("[data-bucket='static']")).toBeNull(); + expect(host.querySelector("[data-testid='pause-nothing-live']")).toBeNull(); + }); + + it("shows the 'Nothing live to pause' status when no run is live", () => { + const summary = computePauseAffectsSummary([ + { assigneeAgentId: null, assigneeUserId: "u1", activeRun: null }, + ]); + const host = mount(); + const note = host.querySelector("[data-testid='pause-nothing-live']")!; + expect(note.getAttribute("role")).toBe("status"); + expect(note.textContent).toContain("Nothing live to pause"); + }); +}); diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx index 329ab688a05..38b14e343a1 100644 --- a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx +++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx @@ -1,11 +1,13 @@ -import { Info, User, X } from "lucide-react"; +import { AlertTriangle, Info, PauseCircle, User, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { AgentIcon } from "../AgentIconPicker"; import { classifyAssigneeHandoff, resolveRunStatusPresentation, type ComposerHandoffPreview, + type PauseAffectsSummary, type PlainAgentNameCandidate, + type ReassignInterruptCopy, type TimelineAssigneeLike, } from "../../lib/interrupt-handoff"; @@ -223,3 +225,125 @@ export function ComposerMentionCoach({
); } + +/** Live banner shown at the top of the assignee picker while a run is in flight, + * warning that reassigning will interrupt it. (design surface 2) */ +export function AssigneeRunningBanner({ + copy, + className, +}: { + copy: ReassignInterruptCopy; + className?: string; +}) { + return ( +
+ + {copy.banner} +
+ ); +} + +/** "Interrupt & assign" confirm step shown when an operator picks a different + * target while a run is live. (design surface 2) */ +export function InterruptAssignConfirm({ + copy, + to, + resolvers, + onConfirm, + onCancel, +}: { + copy: ReassignInterruptCopy; + /** The target the operator selected. */ + to: TimelineAssigneeLike; + resolvers: HandoffChipResolvers; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+
+ +
+

{copy.confirmTitle}

+

+ Hand off to + +

+
+
+
+ + +
+
+ ); +} + +/** "What this affects" bucket summary for the pause/hold dialog. (design surface 4) */ +export function PauseAffectsSummaryView({ + summary, + className, +}: { + summary: PauseAffectsSummary; + className?: string; +}) { + const visibleBuckets = summary.buckets.filter((bucket) => bucket.count > 0); + return ( +
+
+ + What this affects +
+ {summary.nothingLive ? ( +

+ Nothing live to pause — no agent run is in flight or queued. This records a hold so new work + won't start until you resume. +

+ ) : null} + {visibleBuckets.length > 0 ? ( +
    + {visibleBuckets.map((bucket) => ( +
  • + {bucket.label}: + {bucket.count} + — {bucket.detail} +
  • + ))} +
+ ) : ( +

No tasks are affected.

+ )} +
+ ); +} diff --git a/ui/src/lib/interrupt-handoff.test.ts b/ui/src/lib/interrupt-handoff.test.ts index af1188e4cb2..45c1ff201d8 100644 --- a/ui/src/lib/interrupt-handoff.test.ts +++ b/ui/src/lib/interrupt-handoff.test.ts @@ -3,11 +3,14 @@ import { buildAgentMentionHref } from "@paperclipai/shared"; import { bodyHasAgentMention, classifyAssigneeHandoff, + computePauseAffectsSummary, computeComposerHandoffPreview, + describeReassignInterrupt, extractAgentMentionIds, findPlainAgentNameCandidate, isOperatorInterruptedRun, resolveRunStatusPresentation, + type PauseAffectsIssueLike, } from "./interrupt-handoff"; const QA_ID = "agent-qa-1111"; @@ -181,3 +184,68 @@ describe("classifyAssigneeHandoff", () => { expect(info.wakeText).toMatch(/no agent selected/); }); }); + +describe("describeReassignInterrupt", () => { + it("names the running agent in the banner and confirm", () => { + const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" }); + expect(copy.banner).toBe("ClaudeCoder is running — changing the assignee will interrupt this run."); + expect(copy.confirmAction).toBe("Interrupt & assign"); + expect(copy.cancelAction).toBe("Cancel"); + }); + + it("falls back to a generic subject when no name is known", () => { + expect(describeReassignInterrupt().banner).toMatch(/^An agent is running/); + expect(describeReassignInterrupt({ runningAgentName: " " }).banner).toMatch(/^An agent is running/); + }); +}); + +describe("computePauseAffectsSummary", () => { + const issue = (over: Partial): PauseAffectsIssueLike => ({ + assigneeAgentId: null, + assigneeUserId: null, + activeRun: null, + ...over, + }); + const bucket = (summary: ReturnType, key: string) => + summary.buckets.find((b) => b.key === key)!; + + it("partitions affected issues into disjoint buckets", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }), + issue({ assigneeAgentId: "a2", activeRun: { status: "queued" } }), + issue({ assigneeAgentId: "a3" }), + issue({ assigneeUserId: "u1" }), + issue({}), + ]); + expect(bucket(summary, "live_runs").count).toBe(1); + expect(bucket(summary, "queued_wakes").count).toBe(1); + expect(bucket(summary, "agent_owned").count).toBe(1); + expect(bucket(summary, "human_owned").count).toBe(1); + expect(bucket(summary, "static").count).toBe(1); + expect(summary.affectedIssueCount).toBe(5); + expect(summary.nothingLive).toBe(false); + }); + + it("ignores skipped issues", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" }, skipped: true }), + issue({ assigneeUserId: "u1" }), + ]); + expect(summary.affectedIssueCount).toBe(1); + expect(bucket(summary, "live_runs").count).toBe(0); + expect(bucket(summary, "human_owned").count).toBe(1); + }); + + it("flags nothingLive when no run is live or queued", () => { + const summary = computePauseAffectsSummary([issue({ assigneeUserId: "u1" }), issue({})]); + expect(summary.nothingLive).toBe(true); + }); + + it("an active run wins over the issue's owner bucket", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }), + ]); + expect(bucket(summary, "live_runs").count).toBe(1); + expect(bucket(summary, "agent_owned").count).toBe(0); + }); +}); diff --git a/ui/src/lib/interrupt-handoff.ts b/ui/src/lib/interrupt-handoff.ts index e81ed680a03..6d71afc6c11 100644 --- a/ui/src/lib/interrupt-handoff.ts +++ b/ui/src/lib/interrupt-handoff.ts @@ -313,3 +313,127 @@ export function classifyAssigneeHandoff( wakeText: "not created — no agent selected. Mention @agent or pick an assignee to dispatch.", }; } + +// --- Standalone assignee picker interrupt (PAP-10675, design surface 2) ------- + +export interface ReassignInterruptCopy { + /** `role=status` banner shown while a run is live and the picker is open. */ + banner: string; + /** Heading for the "Interrupt & assign" confirm step. */ + confirmTitle: string; + /** Primary action label for the confirm step. */ + confirmAction: string; + /** Label for backing out of the confirm step. */ + cancelAction: string; +} + +/** + * Copy for the assignee picker's live-run states: a banner warning that an + * in-flight run will be interrupted, and the confirm step shown when the + * operator picks a *different* target mid-run. Naming the running agent keeps + * the interrupt consequence concrete instead of a bare "are you sure". + */ +export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy { + const who = opts.runningAgentName?.trim() || "An agent"; + return { + banner: `${who} is running — changing the assignee will interrupt this run.`, + confirmTitle: "Interrupt the current run?", + confirmAction: "Interrupt & assign", + cancelAction: "Cancel", + }; +} + +// --- Pause/hold "What this affects" buckets (PAP-10675, design surface 4) ------ + +export type PauseAffectsBucketKey = + | "live_runs" + | "queued_wakes" + | "agent_owned" + | "human_owned" + | "static"; + +export interface PauseAffectsIssueLike { + assigneeAgentId: string | null; + assigneeUserId: string | null; + activeRun: { status: "queued" | "running" } | null; + skipped?: boolean; +} + +export interface PauseAffectsBucket { + key: PauseAffectsBucketKey; + label: string; + count: number; + /** One-line clarifier of what pausing does to this bucket. */ + detail: string; +} + +export interface PauseAffectsSummary { + buckets: PauseAffectsBucket[]; + /** Total non-skipped issues the operation affects. */ + affectedIssueCount: number; + /** True when no run is live or queued — there is nothing to interrupt. */ + nothingLive: boolean; +} + +const PAUSE_BUCKET_LABEL: Record = { + live_runs: "Live agent runs", + queued_wakes: "Queued wakes", + agent_owned: "Agent-owned", + human_owned: "Human-owned", + static: "Static", +}; + +const PAUSE_BUCKET_DETAIL: Record = { + live_runs: "interrupted now, re-queued when you resume", + queued_wakes: "held — they won't start until you resume", + agent_owned: "assigned to an agent; no run is live", + human_owned: "owned by a board user; pausing won't notify them", + static: "no assignee; nothing was going to run", +}; + +/** + * Partition the issues an operation affects into the five disjoint buckets the + * pause dialog summarises. Each non-skipped issue lands in exactly one bucket: + * a live run, a queued wake, or — when nothing is in flight — by owner kind. + */ +export function computePauseAffectsSummary( + issues: readonly PauseAffectsIssueLike[], +): PauseAffectsSummary { + const counts: Record = { + live_runs: 0, + queued_wakes: 0, + agent_owned: 0, + human_owned: 0, + static: 0, + }; + let affectedIssueCount = 0; + + for (const issue of issues) { + if (issue.skipped) continue; + affectedIssueCount += 1; + if (issue.activeRun?.status === "running") counts.live_runs += 1; + else if (issue.activeRun?.status === "queued") counts.queued_wakes += 1; + else if (issue.assigneeAgentId) counts.agent_owned += 1; + else if (issue.assigneeUserId) counts.human_owned += 1; + else counts.static += 1; + } + + const order: PauseAffectsBucketKey[] = [ + "live_runs", + "queued_wakes", + "agent_owned", + "human_owned", + "static", + ]; + + return { + buckets: order.map((key) => ({ + key, + label: PAUSE_BUCKET_LABEL[key], + count: counts[key], + detail: PAUSE_BUCKET_DETAIL[key], + })), + affectedIssueCount, + nothingLive: counts.live_runs === 0 && counts.queued_wakes === 0, + }; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a286121c18c..fa4af1979fd 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -83,6 +83,8 @@ import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard"; import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; +import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; +import { computePauseAffectsSummary } from "../lib/interrupt-handoff"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import type { MentionOption } from "../components/MarkdownEditor"; @@ -2804,6 +2806,7 @@ export function IssueDetail() { childIssues={panelChildIssues} onAddSubIssue={openNewSubIssue} onUpdate={handleIssuePropertiesUpdate} + hasActiveRun={resolvedHasActiveRun} /> ); return () => closePanel(); @@ -2815,6 +2818,7 @@ export function IssueDetail() { openPanel, panelChildIssues, panelIssue, + resolvedHasActiveRun, ]); const goToInboxShortcutArmedRef = useRef(false); @@ -3232,6 +3236,11 @@ export function IssueDetail() { () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), [treeControlPreview], ); + // "What this affects" buckets for the pause/hold dialog (design surface 4). + const pauseAffectsSummary = useMemo( + () => computePauseAffectsSummary(treeControlPreview?.issues ?? []), + [treeControlPreview], + ); const treePreviewDisplayIssues = useMemo( () => { const previewIssues = treeControlPreview?.issues ?? []; @@ -4240,6 +4249,9 @@ export function IssueDetail() { ) : treeControlPreview ? (
+ {treeControlMode === "pause" ? ( + + ) : null} {treePreviewWarnings.length > 0 ? (
{treePreviewWarnings.map((warning) => ( @@ -4309,6 +4321,7 @@ export function IssueDetail() { onAddSubIssue={openNewSubIssue} onUpdate={(data) => updateIssue.mutate(data)} inline + hasActiveRun={resolvedHasActiveRun} />
diff --git a/ui/storybook/stories/interrupt-handoff.stories.tsx b/ui/storybook/stories/interrupt-handoff.stories.tsx index 19e4eb7fea9..9c1f2f0c9dc 100644 --- a/ui/storybook/stories/interrupt-handoff.stories.tsx +++ b/ui/storybook/stories/interrupt-handoff.stories.tsx @@ -1,15 +1,22 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, User } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AssigneeChip, + AssigneeRunningBanner, ComposerHandoffPreviewRow, ComposerMentionCoach, HandoffWakeRow, + InterruptAssignConfirm, + PauseAffectsSummaryView, RunStatusBadge, type HandoffChipResolvers, } from "@/components/interrupt-handoff/InterruptHandoffViews"; -import { computeComposerHandoffPreview } from "@/lib/interrupt-handoff"; +import { + computeComposerHandoffPreview, + computePauseAffectsSummary, + describeReassignInterrupt, +} from "@/lib/interrupt-handoff"; /** * Visual states for the interrupt-handoff UX clarity work (PAP-10669). Each card @@ -161,3 +168,68 @@ export const PlainTextWarning: Story = { ); }, }; + +/** Surface 2 — standalone assignee picker: grouped sections, the live-run + * banner, and the "Interrupt & assign" confirm step shown mid-run. */ +export const AssigneePicker: Story = { + render: () => { + const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" }); + const sectionHeader = (text: string) => ( +
+ {text} +
+ ); + return ( +
+
+
+ +
+ {sectionHeader("Agents")} + + + {sectionHeader("Board users")} + +
+
+ {}} + onCancel={() => {}} + /> +
+
+ ); + }, +}; + +/** Surface 4 — pause/hold dialog "What this affects" bucket summary. */ +export const PauseAffects: Story = { + render: () => { + const live = computePauseAffectsSummary([ + { assigneeAgentId: "agent-claude", assigneeUserId: null, activeRun: { status: "running" } }, + { assigneeAgentId: "agent-qa", assigneeUserId: null, activeRun: { status: "queued" } }, + { assigneeAgentId: "agent-claude", assigneeUserId: null, activeRun: null }, + { assigneeAgentId: null, assigneeUserId: "user-board", activeRun: null }, + { assigneeAgentId: null, assigneeUserId: null, activeRun: null }, + ]); + const idle = computePauseAffectsSummary([ + { assigneeAgentId: null, assigneeUserId: "user-board", activeRun: null }, + { assigneeAgentId: null, assigneeUserId: null, activeRun: null }, + ]); + return ( +
+ + +
+ ); + }, +};