mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
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:
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, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user