Phase 3B follow-up: assignee picker interrupt clarity + pause affects summary

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 ("<agent> 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-08 21:11:12 +00:00
1 parent 6adc0d85c3
commit c0b0257334
8 files changed
+733 -85

No files matched your search

@@ -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<typeof mockAgentsApi.list.mockResolvedValue>[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<HTMLButtonElement>(
"[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, {
+193 -81
View File
@@ -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<string, unknown>) => 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) => (
<button
key={option.value || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
option.value === selectedAssigneeValue && "bg-accent",
)}
onClick={() => {
if (option.kind === "agent") {
selectAssignee({ assigneeAgentId: option.agent.id, assigneeUserId: null }, option.label, () =>
trackRecentAssignee(option.agent.id),
);
} else if (option.kind === "user") {
selectAssignee({ assigneeAgentId: null, assigneeUserId: option.userId }, option.label, () =>
trackRecentAssigneeUser(option.userId),
);
} else {
selectAssignee({ assigneeAgentId: null, assigneeUserId: null }, option.label);
}
}}
>
{option.kind === "agent" ? (
<AgentIcon icon={option.agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
) : option.kind === "user" ? (
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.value === selectedAssigneeValue ? (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />
) : null}
</button>
);
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) => (
<div className="px-2 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{text}
</div>
);
const assigneeContent = pendingAssignee ? (
<div className="space-y-2 p-1">
<InterruptAssignConfirm
copy={reassignInterruptCopy}
to={{ agentId: pendingAssignee.assigneeAgentId, userId: pendingAssignee.assigneeUserId }}
resolvers={handoffResolvers}
onConfirm={() =>
applyAssignee(
{ assigneeAgentId: pendingAssignee.assigneeAgentId, assigneeUserId: pendingAssignee.assigneeUserId },
pendingAssignee.track,
)
}
onCancel={() => setPendingAssignee(null)}
/>
</div>
) : (
<>
{hasActiveRun ? (
<div className="px-1 pt-1">
<AssigneeRunningBanner copy={reassignInterruptCopy} />
</div>
) : null}
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
@@ -1336,41 +1466,23 @@ export function IssueProperties({
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
{assigneePickerOptions
.filter((option) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return `${option.label} ${option.searchText}`.toLowerCase().includes(q);
})
.map((option) => (
<button
key={option.id || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
option.id === selectedAssigneeValue && "bg-accent",
)}
onClick={() => {
if (option.kind === "agent") {
trackRecentAssignee(option.agent.id);
onUpdate({ assigneeAgentId: option.agent.id, assigneeUserId: null });
} else if (option.kind === "user") {
trackRecentAssigneeUser(option.userId);
onUpdate({ assigneeAgentId: null, assigneeUserId: option.userId });
} else {
onUpdate({ assigneeAgentId: null, assigneeUserId: null });
}
setAssigneeOpen(false);
}}
>
{option.kind === "agent" ? (
<AgentIcon icon={option.agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
) : option.kind === "user" ? (
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
{option.label}
</button>
))}
<div className="max-h-56 overflow-y-auto overscroll-contain">
{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 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matches.</div>
) : null}
</div>
</>
);
@@ -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 ? (
@@ -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(
<AssigneeRunningBanner copy={describeReassignInterrupt({ runningAgentName: "ClaudeCoder" })} />,
);
const banner = host.querySelector<HTMLElement>("[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(
<InterruptAssignConfirm
copy={describeReassignInterrupt({ runningAgentName: "ClaudeCoder" })}
to={{ agentId: "agent-qa", userId: null }}
resolvers={resolvers}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
expect(host.querySelector("[data-testid='handoff-assignee-chip']")?.textContent).toContain("QA");
const confirmBtn = host.querySelector<HTMLButtonElement>("[data-testid='interrupt-assign-confirm-action']")!;
act(() => confirmBtn.click());
expect(onConfirm).toHaveBeenCalledOnce();
const cancelBtn = Array.from(host.querySelectorAll<HTMLButtonElement>("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(<PauseAffectsSummaryView summary={summary} />);
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(<PauseAffectsSummaryView summary={summary} />);
const note = host.querySelector<HTMLElement>("[data-testid='pause-nothing-live']")!;
expect(note.getAttribute("role")).toBe("status");
expect(note.textContent).toContain("Nothing live to pause");
});
});
@@ -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({
</div>
);
}
/** 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 (
<div
role="status"
aria-live="polite"
data-testid="assignee-running-banner"
className={cn(
"flex items-start gap-1.5 rounded-md border border-amber-300/40 bg-amber-50/70 px-2 py-1.5 text-xs text-amber-700 dark:bg-amber-500/10 dark:text-amber-300",
className,
)}
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{copy.banner}</span>
</div>
);
}
/** "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 (
<div
data-testid="interrupt-assign-confirm"
className="space-y-2 rounded-md border border-amber-300/40 bg-amber-50/70 p-2 text-xs text-amber-700 dark:bg-amber-500/10 dark:text-amber-300"
>
<div className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">{copy.confirmTitle}</p>
<p className="flex flex-wrap items-center gap-1 text-amber-700/90 dark:text-amber-300/90">
<span>Hand off to</span>
<AssigneeChip assignee={to} resolvers={resolvers} />
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded border border-amber-400/50 px-2 py-0.5 font-medium hover:bg-amber-100/60 dark:hover:bg-amber-500/20"
>
{copy.cancelAction}
</button>
<button
type="button"
onClick={onConfirm}
data-testid="interrupt-assign-confirm-action"
className="rounded bg-amber-600 px-2 py-0.5 font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
>
{copy.confirmAction}
</button>
</div>
</div>
);
}
/** "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 (
<div
data-testid="pause-affects-summary"
className={cn("space-y-2 rounded-md border border-border bg-muted/30 p-3", className)}
>
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<PauseCircle className="h-3.5 w-3.5" aria-hidden />
What this affects
</div>
{summary.nothingLive ? (
<p role="status" className="text-xs text-muted-foreground" data-testid="pause-nothing-live">
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.
</p>
) : null}
{visibleBuckets.length > 0 ? (
<ul className="space-y-1">
{visibleBuckets.map((bucket) => (
<li
key={bucket.key}
data-bucket={bucket.key}
className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-xs"
>
<span className="font-medium text-foreground">{bucket.label}:</span>
<span className="tabular-nums text-foreground">{bucket.count}</span>
<span className="text-muted-foreground">— {bucket.detail}</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">No tasks are affected.</p>
)}
</div>
);
}
+68
View File
@@ -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>): PauseAffectsIssueLike => ({
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
...over,
});
const bucket = (summary: ReturnType<typeof computePauseAffectsSummary>, 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);
});
});
+124
View File
@@ -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<PauseAffectsBucketKey, string> = {
live_runs: "Live agent runs",
queued_wakes: "Queued wakes",
agent_owned: "Agent-owned",
human_owned: "Human-owned",
static: "Static",
};
const PAUSE_BUCKET_DETAIL: Record<PauseAffectsBucketKey, string> = {
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<PauseAffectsBucketKey, number> = {
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,
};
}
+13
View File
@@ -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() {
</div>
) : treeControlPreview ? (
<div className="space-y-2">
{treeControlMode === "pause" ? (
<PauseAffectsSummaryView summary={pauseAffectsSummary} />
) : null}
{treePreviewWarnings.length > 0 ? (
<div className="space-y-1">
{treePreviewWarnings.map((warning) => (
@@ -4309,6 +4321,7 @@ export function IssueDetail() {
onAddSubIssue={openNewSubIssue}
onUpdate={(data) => updateIssue.mutate(data)}
inline
hasActiveRun={resolvedHasActiveRun}
/>
</div>
</ScrollArea>
@@ -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) => (
<div className="px-2 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{text}
</div>
);
return (
<div className="flex flex-col gap-6 p-4 sm:flex-row" data-testid="interrupt-handoff-assignee-picker">
<div className="w-60 space-y-1 rounded-md border border-border bg-popover p-1">
<div className="px-1 pt-1">
<AssigneeRunningBanner copy={copy} />
</div>
{sectionHeader("Agents")}
<button className="flex w-full items-center gap-2 rounded bg-accent px-2 py-1.5 text-left text-xs">
ClaudeCoder
</button>
<button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50">
QA
</button>
{sectionHeader("Board users")}
<button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50">
<User className="h-3 w-3 text-muted-foreground" /> Assign to me
</button>
</div>
<div className="w-60 rounded-md border border-border bg-popover p-1">
<InterruptAssignConfirm
copy={copy}
to={qa}
resolvers={resolvers}
onConfirm={() => {}}
onCancel={() => {}}
/>
</div>
</div>
);
},
};
/** 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 (
<div className="flex max-w-xl flex-col gap-4 p-4" data-testid="interrupt-handoff-pause-affects">
<PauseAffectsSummaryView summary={live} />
<PauseAffectsSummaryView summary={idle} />
</div>
);
},
};