[codex] Clarify interrupt handoffs and scoped wake semantics (#7855)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue thread is the operator surface where comments, assignee
changes, pauses, resumes, and wakeups turn human intent into agent
execution.
> - Interrupting a live run and handing work to another assignee needs
clear semantics so the product does not accidentally keep work alive,
wake the wrong participant, or hide why an agent stopped.
> - Comment-driven wakes also need strict boundaries so closed, blocked,
and dependency-driven work only resumes when there is real actionable
input.
> - This pull request codifies the interrupt handoff contract,
implements backend scheduling behavior, and gives the UI clearer
handoff/pause language.
> - The benefit is a more inspectable and predictable task lifecycle for
both operators and agents.

## Linked Issues or Issue Description

Paperclip issue: `PAP-10664` / `PAP-10751`.

Problem: interrupting or reassigning live agent work could be ambiguous
in the UI and backend. Operators needed clearer feedback about whether a
handoff wakes an agent, what pause/cancel affects, and when comments
should revive execution. The backend also needed stronger tests around
comment wake boundaries, retry supersession, and structured agent
mention dispatch.

Related GitHub PR search found broad workflow-adjacent PRs #5082, #6359,
and #4083, but no exact duplicate for this head branch or
interrupt-handoff scope.

## What Changed

- Added an interrupt handoff semantics document covering destination
behavior, wake expectations, and live-run interruption states.
- Implemented backend interrupt handoff behavior and comment wake/reopen
handling in issue routes/services and heartbeat scheduling.
- Hardened structured agent mention dispatch so mentions resolve through
the intended dispatch path.
- Added UI helpers and components for handoff chips, wake rows,
interrupt banners, pause-affects summaries, and composer guidance.
- Updated the issue properties assignee picker and issue chat/composer
surfaces to make interrupt/reassign behavior clearer.
- Added backend, UI utility, component, and Storybook coverage for the
new behavior.
- Stabilized the new UI component tests with a local `flushSync`-backed
act helper matching existing repo practice in this dependency set.
- Addressed Greptile feedback by threading historical run `errorCode`
through issue-run data and operator-interrupted chat labels.
- Addressed Greptile's cancel ordering concern by terminating/deleting
in-memory heartbeat processes before cancellation status persistence,
with regression coverage for DB update failure.

## Verification

- `git diff --check $(git merge-base HEAD origin/master)..HEAD`
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/interrupt-handoff.test.ts src/lib/issue-chat-messages.test.ts
src/components/IssueProperties.test.tsx
src/components/interrupt-handoff/InterruptHandoffViews.test.tsx
--no-file-parallelism --maxWorkers=1` — 4 files / 91 tests passed before
the Greptile follow-ups.
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-tree-control-service.test.ts
server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
server/src/__tests__/issues-service.test.ts --no-file-parallelism
--maxWorkers=1` — 6 files / 191 tests passed before the Greptile
follow-ups.
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/issue-chat-messages.test.ts --no-file-parallelism
--maxWorkers=1` — 1 file / 24 tests passed after the historical
`errorCode` follow-up.
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/activity-routes.test.ts --no-file-parallelism
--maxWorkers=1` — 2 files / 11 tests passed after the historical
`errorCode` follow-up.
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
--no-file-parallelism --maxWorkers=1` — 1 file / 52 tests passed after
the cancel ordering follow-up.
- Greptile is green for head `272647636287d034bab8d981eaf5305865aa0f96`;
the old inline P2 is resolved/outdated.
- GitHub Actions, Socket, security-review, and Greptile checks are green
for head `272647636287d034bab8d981eaf5305865aa0f96`. The external
`security/snyk (cryppadotta)` status was still pending at
`https://app.snyk.io/org/cryppadotta/pr-checks/85b3e8f4-04e1-4f8e-9362-899c8148c23c`
after a bounded wait.

## Risks

- Medium: changes touch issue comments, wake scheduling, and live-run
interruption semantics, so regressions could affect when agents resume
or stay stopped.
- Medium: UI copy and state grouping for assignee changes may need
reviewer tuning after product review.
- Low migration risk: no database schema migration is included.
- The branch was created before the latest `origin/master` commits;
reviewers should confirm CI merge-base behavior and resolve any merge
conflicts if GitHub reports them.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent, tool use and local command
execution enabled. Exact hosted model build and context window were not
exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Screenshot note: this PR includes Storybook coverage for the new
interrupt handoff UI states rather than captured before/after browser
screenshots in this PR-creation heartbeat.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-09 21:57:21 -05:00
committed by GitHub
parent 5d315ab778
commit 67b22d872f
24 changed files with 2713 additions and 265 deletions
+131 -59
View File
@@ -95,6 +95,20 @@ import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { AgentIcon } from "./AgentIconPicker";
import {
AssigneeChip,
ComposerHandoffPreviewRow,
ComposerMentionCoach,
HandoffWakeRow,
RunStatusBadge,
type HandoffChipResolvers,
} from "./interrupt-handoff/InterruptHandoffViews";
import {
computeComposerHandoffPreview,
extractAgentMentionIds,
findPlainAgentNameCandidate,
type HandoffAgentMention,
} from "../lib/interrupt-handoff";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import {
captureComposerViewportSnapshot,
@@ -132,6 +146,7 @@ import {
summarizeToolInput,
summarizeToolResult,
} from "../lib/transcriptPresentation";
import { buildAgentMentionHref } from "@paperclipai/shared";
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -302,6 +317,10 @@ interface IssueChatComposerProps {
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
/** Whether an agent run is currently in flight, so the composer can preview an interrupt. */
hasActiveRun?: boolean;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
composerDisabledReason?: string | null;
composerHint?: string | null;
issueStatus?: string;
@@ -721,21 +740,6 @@ function humanizeValue(value: string | null) {
return value.replace(/_/g, " ");
}
function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | null,
) {
if (assignee.agentId) {
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
}
if (assignee.userId) {
return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board";
}
return "Unassigned";
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
@@ -781,36 +785,6 @@ export function resolveIssueChatHumanAuthor(args: {
};
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
return "timed out";
default:
return status.replace(/_/g, " ");
}
}
function runStatusClass(status: string) {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-cyan-700 dark:text-cyan-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null {
if (toolParts.length === 0) return null;
let commands = 0;
@@ -2552,6 +2526,11 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
const isAgent = actorType === "agent";
const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
const handoffResolvers: HandoffChipResolvers = {
agentMap,
currentUserId,
resolveUserLabel: (userId) => formatAssigneeUserLabel(userId, null, userLabelMap),
};
const eventContent = (
<div className="min-w-0 space-y-1">
@@ -2580,17 +2559,22 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
) : null}
{assigneeChange ? (
<div className={cn("flex flex-wrap items-center gap-1.5 text-xs", isCurrentUser && "justify-end")}>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
</span>
<div className="space-y-1">
<div className={cn("flex flex-wrap items-center gap-1.5 text-xs", isCurrentUser && "justify-end")}>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Assignee
</span>
<AssigneeChip assignee={assigneeChange.from} resolvers={handoffResolvers} />
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<AssigneeChip assignee={assigneeChange.to} resolvers={handoffResolvers} />
</div>
<div className={cn(isCurrentUser && "flex justify-end")}>
<HandoffWakeRow
to={assigneeChange.to}
resolvers={handoffResolvers}
interruptedRunAttached={custom.interruptedRunId != null}
/>
</div>
</div>
) : null}
@@ -2665,9 +2649,10 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
>
{runId.slice(0, 8)}
</Link>
<span className={cn("font-medium", runStatusClass(runStatus))}>
{formatRunStatusLabel(runStatus)}
</span>
<RunStatusBadge
status={runStatus}
operatorInterrupted={custom.runOperatorInterrupted === true}
/>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
@@ -3274,6 +3259,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
suggestedAssigneeValue,
mentions = [],
agentMap,
hasActiveRun = false,
currentUserId = null,
userLabelMap = null,
composerDisabledReason = null,
composerHint = null,
issueStatus,
@@ -3291,6 +3279,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [unassignedConfirmed, setUnassignedConfirmed] = useState(false);
const [dismissedCoachToken, setDismissedCoachToken] = useState<string | null>(null);
const resolvedIssueWorkMode: IssueWorkMode = issueWorkMode ?? "standard";
const [pendingWorkMode, setPendingWorkMode] = useState<IssueWorkMode>(resolvedIssueWorkMode);
const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false);
@@ -3548,6 +3537,67 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const canSubmit = !submitting && !!body.trim();
// Interrupt-handoff clarity (PAP-10669): preview what this comment will durably
// do, and coach plain agent names toward real mentions.
const agentMentionOptions = useMemo<HandoffAgentMention[]>(
() =>
mentions
.filter((m) => (m.kind ?? "agent") === "agent" && (m.agentId ?? m.id))
.map((m) => ({ agentId: m.agentId ?? m.id.replace(/^agent:/, ""), name: m.name })),
[mentions],
);
const handoffResolvers = useMemo<HandoffChipResolvers>(
() => ({
agentMap,
currentUserId,
resolveUserLabel: (userId: string) => formatAssigneeUserLabel(userId, null, userLabelMap),
}),
[agentMap, currentUserId, userLabelMap],
);
const mentionedAgentIds = useMemo(() => extractAgentMentionIds(body), [body]);
const plainNameCandidate = useMemo(
() => (mentionedAgentIds.length > 0 ? null : findPlainAgentNameCandidate(body, agentMentionOptions)),
[body, mentionedAgentIds, agentMentionOptions],
);
const handoffPreview = useMemo(
() =>
computeComposerHandoffPreview({
reassignTarget,
currentAssigneeValue,
hasActiveRun,
bodyHasAgentMention: mentionedAgentIds.length > 0,
mentionedAgentId: mentionedAgentIds[0] ?? null,
plainNameCandidate,
}),
[reassignTarget, currentAssigneeValue, hasActiveRun, mentionedAgentIds, plainNameCandidate],
);
const coachVisible = Boolean(
plainNameCandidate && plainNameCandidate.matchedText !== dismissedCoachToken,
);
const coachAgentName = plainNameCandidate
? agentMap?.get(plainNameCandidate.agentId)?.name ?? plainNameCandidate.matchedText
: "";
function insertCoachMention() {
if (!plainNameCandidate) return;
const option = mentions.find(
(m) => (m.agentId ?? m.id.replace(/^agent:/, "")) === plainNameCandidate.agentId,
);
const agentId = plainNameCandidate.agentId;
const name = option?.name ?? plainNameCandidate.matchedText;
const markdown = `[@${name}](${buildAgentMentionHref(agentId, option?.agentIcon ?? null)}) `;
// Replace the first bare occurrence of the matched token (outside links).
const tokenRe = new RegExp(
`(?<![\\w@/])${plainNameCandidate.matchedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/])`,
"i",
);
setBody((current) => {
if (tokenRe.test(current)) return current.replace(tokenRe, markdown.trimEnd());
return current ? `${current} ${markdown}` : markdown;
});
setDismissedCoachToken(plainNameCandidate.matchedText);
}
if (composerDisabledReason) {
return (
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
@@ -3605,6 +3655,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide"
/>
{coachVisible && plainNameCandidate ? (
<div className="mt-2">
<ComposerMentionCoach
candidate={plainNameCandidate}
agentDisplayName={coachAgentName}
onInsert={insertCoachMention}
onDismiss={() => setDismissedCoachToken(plainNameCandidate.matchedText)}
/>
</div>
) : null}
{composerHint ? (
<div className="inline-flex items-center rounded-full border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground">
{composerHint}
@@ -3656,6 +3717,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
</div>
) : null}
{body.trim() ? (
<ComposerHandoffPreviewRow preview={handoffPreview} resolvers={handoffResolvers} />
) : null}
<div className="flex flex-wrap items-center justify-end gap-3">
<div className="mr-auto flex items-center gap-2">
{(onImageUpload || onAttachImage) ? (
@@ -3900,6 +3965,10 @@ export function IssueChatThread({
}
return ids;
}, [displayLiveRuns]);
const hasActiveRun = useMemo(
() => displayLiveRuns.some((run) => run.status === "running") || activeRun?.status === "running",
[displayLiveRuns, activeRun],
);
const clearLatestSettleTimeouts = useCallback(() => {
for (const timeout of latestSettleTimeoutsRef.current) {
window.clearTimeout(timeout);
@@ -4532,6 +4601,9 @@ export function IssueChatThread({
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentions}
agentMap={agentMap}
hasActiveRun={!!hasActiveRun}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
composerDisabledReason={composerDisabledReason}
composerHint={composerHint}
issueStatus={issueStatus}
+123 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type {
ExecutionWorkspace,
@@ -115,6 +115,14 @@ vi.mock("@/components/ui/popover", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -385,6 +393,120 @@ 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("filters the no-assignee option with assignee search", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number],
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const searchInput = container.querySelector(
'input[placeholder="Search assignees..."]',
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "no");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("No assignee");
expect(container.textContent).not.toContain("No matches.");
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "zzzz");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).not.toContain("No assignee");
expect(container.textContent).toContain("No matches.");
act(() => root.unmount());
});
it("always exposes the add sub-issue action", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
+196 -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,122 @@ 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 showNoAssigneeOption = matchesAssigneeSearch("No assignee", "");
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 +1467,25 @@ 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">
{showNoAssigneeOption
? renderAssigneeOption({ kind: "none", value: "", label: "No assignee", searchText: "" })
: null}
{visibleAgentOptions.length > 0 ? (
<>
{sectionHeader("Agents")}
{visibleAgentOptions.map((option) => renderAssigneeOption(option))}
</>
) : null}
{visibleUserOptions.length > 0 ? (
<>
{sectionHeader("Board users")}
{visibleUserOptions.map((option) => renderAssigneeOption(option))}
</>
) : null}
{!showNoAssigneeOption && visibleAgentOptions.length === 0 && visibleUserOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matches.</div>
) : null}
</div>
</>
);
@@ -1789,7 +1904,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 ? (
@@ -0,0 +1,258 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
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,
computePauseAffectsSummary,
describeReassignInterrupt,
} from "../../lib/interrupt-handoff";
const resolvers: HandoffChipResolvers = {
agentMap: new Map([
["agent-qa", { name: "QA", icon: null }],
["agent-claude", { name: "ClaudeCoder", icon: null }],
]),
resolveUserLabel: (id) => (id === "user-board" ? "Riley Board" : null),
currentUserId: "user-board",
};
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
let activeRoot: Root | null = null;
let activeHost: HTMLDivElement | null = null;
function mount(node: ReactNode): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() => {
root.render(node);
});
activeRoot = root;
activeHost = host;
return host;
}
afterEach(() => {
if (activeRoot) {
const root = activeRoot;
act(() => root.unmount());
}
activeHost?.remove();
activeRoot = null;
activeHost = null;
});
describe("AssigneeChip", () => {
it("renders an agent chip with a screen-reader 'Agent' prefix", () => {
const host = mount(<AssigneeChip assignee={{ agentId: "agent-qa", userId: null }} resolvers={resolvers} />);
const chip = host.querySelector<HTMLElement>("[data-testid='handoff-assignee-chip']")!;
expect(chip.dataset.kind).toBe("agent");
expect(chip.textContent).toContain("Agent");
expect(chip.textContent).toContain("QA");
});
it("renders a user chip with '(you)' and a 'User' prefix — never an agent look", () => {
const host = mount(<AssigneeChip assignee={{ agentId: null, userId: "user-board" }} resolvers={resolvers} />);
const chip = host.querySelector<HTMLElement>("[data-testid='handoff-assignee-chip']")!;
expect(chip.dataset.kind).toBe("user");
expect(chip.textContent).toContain("User");
expect(chip.textContent).toContain("Riley Board (you)");
});
it("renders unassigned as italic text, not a chip", () => {
const host = mount(<AssigneeChip assignee={{ agentId: null, userId: null }} resolvers={resolvers} />);
const chip = host.querySelector<HTMLElement>("[data-testid='handoff-assignee-chip']")!;
expect(chip.dataset.kind).toBe("unassigned");
expect(chip.textContent).toContain("Unassigned");
});
});
describe("HandoffWakeRow", () => {
it("A. agent destination = queued wake", () => {
const host = mount(
<HandoffWakeRow to={{ agentId: "agent-qa", userId: null }} resolvers={resolvers} interruptedRunAttached />,
);
const row = host.querySelector<HTMLElement>("[data-testid='handoff-wake-row']")!;
expect(row.dataset.kind).toBe("agent_wake");
expect(row.textContent).toContain("queued for QA");
expect(row.textContent).toContain("interrupted run attached");
});
it("B. user destination = no wake", () => {
const host = mount(<HandoffWakeRow to={{ agentId: null, userId: "user-board" }} resolvers={resolvers} />);
const row = host.querySelector<HTMLElement>("[data-testid='handoff-wake-row']")!;
expect(row.dataset.kind).toBe("user_handoff");
expect(row.textContent).toContain("not created");
});
it("C. unassigned = no agent selected", () => {
const host = mount(<HandoffWakeRow to={{ agentId: null, userId: null }} resolvers={resolvers} />);
const row = host.querySelector<HTMLElement>("[data-testid='handoff-wake-row']")!;
expect(row.dataset.kind).toBe("unassigned");
expect(row.textContent).toContain("no agent selected");
});
});
describe("RunStatusBadge", () => {
it("shows amber 'interrupted' for an operator interrupt", () => {
const host = mount(<RunStatusBadge status="cancelled" operatorInterrupted />);
const badge = host.querySelector<HTMLElement>("[data-testid='run-status-badge']")!;
expect(badge.textContent).toContain("interrupted");
expect(badge.className).toContain("amber");
expect(badge.dataset.interrupted).toBe("true");
});
it("shows muted 'cancelled' for a plain cancel", () => {
const host = mount(<RunStatusBadge status="cancelled" />);
const badge = host.querySelector<HTMLElement>("[data-testid='run-status-badge']")!;
expect(badge.textContent).toContain("cancelled");
expect(badge.className).toContain("muted");
});
});
describe("ComposerHandoffPreviewRow", () => {
it("renders the interrupt+handoff intent with an agent chip", () => {
const preview = computeComposerHandoffPreview({
reassignTarget: "agent:agent-qa",
currentAssigneeValue: "agent:agent-claude",
hasActiveRun: true,
bodyHasAgentMention: false,
plainNameCandidate: null,
});
const host = mount(<ComposerHandoffPreviewRow preview={preview} resolvers={resolvers} />);
const row = host.querySelector<HTMLElement>("[data-testid='composer-handoff-preview']")!;
expect(row.dataset.kind).toBe("interrupt_handoff_agent");
expect(row.textContent).toContain("Interrupt current run, hand off to");
expect(row.textContent).toContain("QA");
});
it("renders the amber plain-text warning with no chip", () => {
const preview = computeComposerHandoffPreview({
reassignTarget: "agent:agent-claude",
currentAssigneeValue: "agent:agent-claude",
hasActiveRun: true,
bodyHasAgentMention: false,
plainNameCandidate: { agentId: "agent-qa", matchedText: "QA" },
});
const host = mount(<ComposerHandoffPreviewRow preview={preview} resolvers={resolvers} />);
const row = host.querySelector<HTMLElement>("[data-testid='composer-handoff-preview']")!;
expect(row.dataset.kind).toBe("plain_text_only");
expect(row.className).toContain("amber");
expect(host.querySelector("[data-testid='handoff-assignee-chip']")).toBeNull();
});
it("renders nothing for the 'none' state", () => {
const preview = computeComposerHandoffPreview({
reassignTarget: "agent:agent-claude",
currentAssigneeValue: "agent:agent-claude",
hasActiveRun: true,
bodyHasAgentMention: false,
plainNameCandidate: null,
});
const host = mount(<ComposerHandoffPreviewRow preview={preview} resolvers={resolvers} />);
expect(host.querySelector("[data-testid='composer-handoff-preview']")).toBeNull();
});
});
describe("ComposerMentionCoach", () => {
it("offers an Insert mention action and is dismissible", () => {
const onInsert = vi.fn();
const onDismiss = vi.fn();
const host = mount(
<ComposerMentionCoach
candidate={{ agentId: "agent-qa", matchedText: "QA" }}
agentDisplayName="QA"
onInsert={onInsert}
onDismiss={onDismiss}
/>,
);
const insertBtn = host.querySelector<HTMLButtonElement>("[aria-label^='Insert mention for QA']")!;
act(() => insertBtn.click());
expect(onInsert).toHaveBeenCalledOnce();
const dismissBtn = host.querySelector<HTMLButtonElement>("[aria-label='Dismiss suggestion']")!;
act(() => dismissBtn.click());
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");
});
});
@@ -0,0 +1,349 @@
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";
/**
* Presentational views for the interrupt-handoff UX clarity surfaces (PAP-10669).
* All logic lives in `lib/interrupt-handoff.ts`; these components only render it,
* so they can be exercised in isolation by component tests and Storybook.
*/
export interface HandoffAgentLike {
name: string;
icon?: string | null;
}
export interface HandoffChipResolvers {
agentMap?: ReadonlyMap<string, HandoffAgentLike> | null;
resolveUserLabel?: (userId: string) => string | null;
currentUserId?: string | null;
}
function agentName(agentId: string, resolvers: HandoffChipResolvers): string {
return resolvers.agentMap?.get(agentId)?.name ?? agentId.slice(0, 8);
}
function agentIcon(agentId: string, resolvers: HandoffChipResolvers): string | null {
return resolvers.agentMap?.get(agentId)?.icon ?? null;
}
function userLabel(userId: string, resolvers: HandoffChipResolvers): string {
const label = resolvers.resolveUserLabel?.(userId) ?? null;
const base = label ?? "Board";
return resolvers.currentUserId && resolvers.currentUserId === userId ? `${base} (you)` : base;
}
const CHIP_CLASS =
"inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs";
/** A labelled assignee chip agent, user, or unassigned that never lets a
* user owner read like an agent. */
export function AssigneeChip({
assignee,
resolvers,
className,
}: {
assignee: TimelineAssigneeLike;
resolvers: HandoffChipResolvers;
className?: string;
}) {
if (assignee.agentId) {
return (
<span className={cn(CHIP_CLASS, className)} data-testid="handoff-assignee-chip" data-kind="agent">
<span className="sr-only">Agent </span>
<AgentIcon icon={agentIcon(assignee.agentId, resolvers)} className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="max-w-[12rem] truncate">{agentName(assignee.agentId, resolvers)}</span>
</span>
);
}
if (assignee.userId) {
return (
<span className={cn(CHIP_CLASS, className)} data-testid="handoff-assignee-chip" data-kind="user">
<span className="sr-only">User </span>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="max-w-[12rem] truncate">{userLabel(assignee.userId, resolvers)}</span>
</span>
);
}
return (
<span
className={cn("text-xs italic text-muted-foreground", className)}
data-testid="handoff-assignee-chip"
data-kind="unassigned"
>
<span className="sr-only">No assignee </span>
Unassigned
</span>
);
}
/** The "Wake" sub-row that makes each handoff state self-describing: a queued
* agent wake, a board-user handoff with no wake, or no agent selected. */
export function HandoffWakeRow({
to,
resolvers,
interruptedRunAttached = false,
}: {
to: TimelineAssigneeLike;
resolvers: HandoffChipResolvers;
interruptedRunAttached?: boolean;
}) {
const info = classifyAssigneeHandoff(to, {
agentName: to.agentId ? agentName(to.agentId, resolvers) : null,
interruptedRunAttached,
});
return (
<div
className="flex flex-wrap items-center gap-1.5 text-xs"
data-testid="handoff-wake-row"
data-kind={info.kind}
>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Wake</span>
<span className={cn(info.kind === "agent_wake" ? "text-foreground" : "text-muted-foreground")}>
{info.wakeText}
</span>
</div>
);
}
/** Run status text that distinguishes an intentional operator interrupt
* (amber "interrupted") from a generic muted "cancelled". */
export function RunStatusBadge({
status,
operatorInterrupted = false,
className,
}: {
status: string;
operatorInterrupted?: boolean;
className?: string;
}) {
const p = resolveRunStatusPresentation(status, { operatorInterrupted });
return (
<span
className={cn("font-medium", p.className, className)}
data-testid="run-status-badge"
data-interrupted={operatorInterrupted ? "true" : "false"}
>
{p.label}
{p.srHint ? <span className="sr-only"> {p.srHint}</span> : null}
</span>
);
}
function PreviewChip({
chip,
resolvers,
}: {
chip: NonNullable<ComposerHandoffPreview["chip"]>;
resolvers: HandoffChipResolvers;
}) {
return (
<AssigneeChip
assignee={chip.kind === "agent" ? { agentId: chip.id, userId: null } : { agentId: null, userId: chip.id }}
resolvers={resolvers}
/>
);
}
/** One-line interpretation of what submitting the comment will durably do. */
export function ComposerHandoffPreviewRow({
preview,
resolvers,
}: {
preview: ComposerHandoffPreview;
resolvers: HandoffChipResolvers;
}) {
if (preview.kind === "none") return null;
return (
<div
className={cn(
"flex flex-wrap items-center justify-end gap-1.5 text-xs",
preview.tone === "warn" ? "text-amber-700 dark:text-amber-300" : "text-muted-foreground",
)}
data-testid="composer-handoff-preview"
data-kind={preview.kind}
role="status"
aria-live="polite"
>
<span>{preview.text}</span>
{preview.chip ? <PreviewChip chip={preview.chip} resolvers={resolvers} /> : null}
{preview.suffix ? <span>{preview.suffix}</span> : null}
</div>
);
}
/** Inline coach shown when the body contains a plain agent name without a chip,
* offering a one-click upgrade to a real mention. */
export function ComposerMentionCoach({
candidate,
agentDisplayName,
onInsert,
onDismiss,
}: {
candidate: PlainAgentNameCandidate;
agentDisplayName: string;
onInsert: () => void;
onDismiss: () => void;
}) {
return (
<div
className="flex items-center gap-2 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"
data-testid="composer-mention-coach"
role="alert"
aria-live="polite"
>
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">
Did you mean <span className="font-medium">@{candidate.matchedText}</span>? Plain text won't
notify or assign an agent.
</span>
<button
type="button"
onClick={onInsert}
className="shrink-0 rounded border border-amber-400/50 px-1.5 py-0.5 font-medium hover:bg-amber-100/60 dark:hover:bg-amber-500/20"
aria-label={`Insert mention for ${agentDisplayName} into your comment`}
>
Insert mention
</button>
<button
type="button"
onClick={onDismiss}
className="shrink-0 rounded p-0.5 hover:bg-amber-100/60 dark:hover:bg-amber-500/20"
aria-label="Dismiss suggestion"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</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>
);
}