Surface source-scoped recovery actions in the UI

Add the IssueRecoveryActionCard so source issues render their active
recovery state (cause, owner, return owner, runs, evidence, next action,
wake/monitor, attempts, timeout, resolution) in the chat thread slot the
successful-run handoff bullet list previously occupied. Surface a
matching Recovery chip on inbox rows, render an info banner on legacy
recovery issues that links to their source, and add the
issue.recovery_action_opened/_resolved/_escalated activity verbs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-09 21:38:40 -05:00
1 parent fcd6e497ca
commit f54aba63fc
8 files changed
+1179 -3

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const path = require("path");
const fs = require("fs");
const repoRoot = path.resolve(__dirname, "..");
const playwright = require(path.join(
repoRoot,
"node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js",
));
const STORYBOOK_BASE = process.env.STORYBOOK_URL ?? "http://localhost:6006";
const OUT_DIR = path.resolve(repoRoot, "tmp/pap-9134-recovery-screens");
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
const STORIES = [
{
id: "paperclip-source-issue-recovery--recovery-action-card-states",
label: "card-states",
},
{
id: "paperclip-source-issue-recovery--inbox-row-chips",
label: "inbox-rows",
},
];
const THEMES = [
{ name: "light", apply: () => document.documentElement.classList.remove("dark") },
{ name: "dark", apply: () => document.documentElement.classList.add("dark") },
];
(async () => {
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await playwright.chromium.launch();
try {
for (const viewport of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 2,
});
for (const story of STORIES) {
for (const theme of THEMES) {
const page = await context.newPage();
const url = `${STORYBOOK_BASE}/iframe.html?id=${story.id}&viewMode=story`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {});
await page.evaluate((darkTheme) => {
const html = document.documentElement;
if (darkTheme) {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}, theme.name === "dark");
await page.waitForTimeout(400);
const outPath = path.join(
OUT_DIR,
`${story.label}_${viewport.name}_${theme.name}.png`,
);
await page.screenshot({ path: outPath, fullPage: true });
console.log(`Saved ${outPath}`);
await page.close();
}
}
await context.close();
}
} finally {
await browser.close();
}
})().catch((err) => {
console.error(err);
process.exit(1);
});
+53 -1
View File
@@ -36,6 +36,7 @@ import type {
FeedbackVoteValue,
IssueAttachment,
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
IssueWorkMode,
@@ -134,6 +135,7 @@ import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
interface IssueChatMessageContext {
feedbackDataSharingPreference: FeedbackDataSharingPreference;
@@ -297,6 +299,14 @@ interface IssueChatThreadProps {
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canCancelRecoveryAction?: boolean;
legacyRecoverySourceIssue?: {
identifier: string | null;
href: string;
title?: string | null;
} | null;
assigneeUserId?: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
@@ -3609,6 +3619,10 @@ export function IssueChatThread({
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
recoveryAction = null,
onResolveRecoveryAction,
canCancelRecoveryAction = false,
legacyRecoverySourceIssue = null,
companyId,
projectId,
issueStatus,
@@ -4244,11 +4258,49 @@ export function IssueChatThread({
onResume={onResumeFromBacklog}
resuming={resumeFromBacklogPending}
/>
{recoveryAction ? (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
canCancelRecovery={canCancelRecoveryAction}
/>
) : null}
{legacyRecoverySourceIssue ? (
<SystemNotice
tone="info"
label="Legacy recovery issue"
body={
<span>
Legacy recovery issue. Newer recovery actions live on the source issue
{legacyRecoverySourceIssue.identifier ? (
<>
{" — "}
<Link
to={legacyRecoverySourceIssue.href}
className="underline-offset-2 hover:underline"
>
{legacyRecoverySourceIssue.identifier}
{legacyRecoverySourceIssue.title ? (
<span className="text-muted-foreground">
{" "}
— {legacyRecoverySourceIssue.title}
</span>
) : null}
</Link>
</>
) : (
"."
)}
</span>
}
/>
) : null}
<IssueBlockedNotice
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
@@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
import { IssueRecoveryActionCard, deriveRecoveryCardState } from "./IssueRecoveryActionCard";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
const ownerAgent: Agent = {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
name: "ClaudeCoder",
role: "engineer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
urlKey: "claudecoder",
} as unknown as Agent;
const returnAgent: Agent = {
...ownerAgent,
id: "22222222-2222-2222-2222-222222222222",
name: "CodexCoder",
urlKey: "codexcoder",
} as Agent;
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000aa",
companyId: "company-1",
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: ownerAgent.id,
ownerUserId: null,
previousOwnerAgentId: returnAgent.id,
returnOwnerAgentId: returnAgent.id,
cause: "missing_disposition",
fingerprint: "fp",
evidence: {
summary: "Run finished but no disposition was chosen.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
nextAction: "Choose and record a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: "2026-05-09T19:30:00.000Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-09T19:30:00.000Z",
updatedAt: "2026-05-09T19:30:00.000Z",
...overrides,
};
}
describe("deriveRecoveryCardState", () => {
it("maps active missing_disposition to needed", () => {
expect(deriveRecoveryCardState(buildAction())).toBe("needed");
});
it("maps active_run_watchdog to observe_only", () => {
expect(deriveRecoveryCardState(buildAction({ kind: "active_run_watchdog" }))).toBe("observe_only");
});
it("maps escalated status to escalated", () => {
expect(deriveRecoveryCardState(buildAction({ status: "escalated" }))).toBe("escalated");
});
it("maps resolved/cancelled to resolved", () => {
expect(deriveRecoveryCardState(buildAction({ status: "resolved" }))).toBe("resolved");
expect(deriveRecoveryCardState(buildAction({ status: "cancelled" }))).toBe("resolved");
});
});
describe("IssueRecoveryActionCard", () => {
it("renders required fields and an aria-label naming the state", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction()}
agentMap={new Map([
[ownerAgent.id, ownerAgent],
[returnAgent.id, returnAgent],
])}
onResolve={() => {}}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed");
expect(node.textContent).toContain("RECOVERY NEEDED");
expect(node.textContent).toContain("missing_disposition");
expect(node.textContent).toContain("This issue's run finished, but no next step was chosen.");
expect(node.textContent).toContain("ClaudeCoder");
expect(node.textContent).toContain("CodexCoder");
expect(node.textContent).toContain("Choose and record a valid issue disposition.");
expect(node.textContent).toContain("Corrective wake queued");
});
it("falls back to em dash when wake policy is absent", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ wakePolicy: null })} />,
);
expect(node.textContent).toContain("—");
});
it("renders observe_only tone for active_run_watchdog", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ kind: "active_run_watchdog" })} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("aria-label")).toBe("Recovery action: observing active run");
expect(node.textContent).toContain("OBSERVING ACTIVE RUN");
});
it("renders the resolved label and outcome when resolved", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ status: "resolved", outcome: "restored", resolvedAt: "2026-05-09T19:35:00.000Z" })} />,
);
expect(node.textContent).toContain("RECOVERY RESOLVED");
expect(node.textContent).toContain("Resolved as restored");
});
it("hides false_positive option unless canCancelRecovery is set", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
);
expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).not.toBeNull();
});
});
@@ -0,0 +1,552 @@
import { useMemo } from "react";
import type {
Agent,
IssueRecoveryAction,
IssueRecoveryActionKind,
IssueRecoveryActionOutcome,
IssueRecoveryActionStatus,
} from "@paperclipai/shared";
import { Eye, OctagonAlert, RefreshCw, Sparkles, TriangleAlert } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { agentUrl } from "@/lib/utils";
import { cn } from "@/lib/utils";
export type RecoveryCardCardState =
| "needed"
| "in_progress"
| "observe_only"
| "escalated"
| "resolved";
export type RecoveryResolveOutcome =
| "done"
| "in_review"
| "blocked"
| "delegated"
| "false_positive";
export interface IssueRecoveryActionCardProps {
action: IssueRecoveryAction;
agentMap?: ReadonlyMap<string, Agent>;
/** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */
forcedState?: RecoveryCardCardState;
/** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */
onResolve?: (outcome: RecoveryResolveOutcome) => void;
/** Whether the viewer can run destructive board-only actions (e.g. cancel recovery). */
canCancelRecovery?: boolean;
className?: string;
}
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
missing_disposition: "missing_disposition",
stranded_assigned_issue: "stranded_assigned_issue",
active_run_watchdog: "active_run_watchdog",
issue_graph_liveness: "issue_graph_liveness",
};
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
missing_disposition: "This issue's run finished, but no next step was chosen.",
stranded_assigned_issue:
"Paperclip retried this issue's last run and it still has no live execution path.",
active_run_watchdog:
"The active run has been silent. Recovery is observing without interrupting it.",
issue_graph_liveness:
"Paperclip detected this issue lost a live action path. A recovery owner needs to act.",
};
const STATE_TONE: Record<RecoveryCardCardState, {
label: string;
containerClass: string;
iconWrapClass: string;
iconClass: string;
labelClass: string;
Icon: typeof TriangleAlert;
divider: string;
}> = {
needed: {
label: "RECOVERY NEEDED",
containerClass:
"border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
iconClass: "text-amber-700 dark:text-amber-300",
labelClass: "text-amber-900 dark:text-amber-200",
Icon: TriangleAlert,
divider: "border-amber-300/60 dark:border-amber-500/30",
},
in_progress: {
label: "RECOVERY IN PROGRESS",
containerClass:
"border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100",
iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200",
iconClass: "text-sky-700 dark:text-sky-300",
labelClass: "text-sky-900 dark:text-sky-200",
Icon: RefreshCw,
divider: "border-sky-300/60 dark:border-sky-500/30",
},
observe_only: {
label: "OBSERVING ACTIVE RUN",
containerClass:
"border-border bg-muted/40 text-foreground dark:bg-muted/20",
iconWrapClass: "bg-muted text-foreground/70",
iconClass: "text-muted-foreground",
labelClass: "text-muted-foreground",
Icon: Eye,
divider: "border-border/70",
},
escalated: {
label: "RECOVERY ESCALATED",
containerClass:
"border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100",
iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200",
iconClass: "text-red-700 dark:text-red-300",
labelClass: "text-red-900 dark:text-red-200",
Icon: OctagonAlert,
divider: "border-red-400/50 dark:border-red-500/30",
},
resolved: {
label: "RECOVERY RESOLVED",
containerClass:
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
iconClass: "text-emerald-700 dark:text-emerald-300",
labelClass: "text-emerald-900 dark:text-emerald-200",
Icon: Sparkles,
divider: "border-emerald-300/60 dark:border-emerald-500/30",
},
};
export function deriveRecoveryCardState(
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
): RecoveryCardCardState {
if (action.status === "resolved") return "resolved";
if (action.status === "escalated") return "escalated";
if (action.status === "cancelled") return "resolved";
if (action.kind === "active_run_watchdog") return "observe_only";
if (action.outcome === "delegated") return "in_progress";
return "needed";
}
const OUTCOME_LABEL: Record<IssueRecoveryActionOutcome, string> = {
restored: "restored",
delegated: "delegated to follow-up",
false_positive: "false positive",
blocked: "blocked",
escalated: "escalated",
cancelled: "cancelled",
};
function readEvidenceString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed;
}
function pickEvidenceSummary(action: IssueRecoveryAction): string | null {
const evidence = action.evidence ?? {};
const candidates = [
"summary",
"detectedProgressSummary",
"missingDisposition",
"retryReason",
"latestRunErrorCode",
"latestRunStatus",
"latestIssueStatus",
] as const;
for (const key of candidates) {
const next = readEvidenceString(evidence[key]);
if (next) return next;
}
return null;
}
function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") {
const evidence = action.evidence ?? {};
const next = readEvidenceString(evidence[key]);
return next;
}
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
const policy = action.wakePolicy;
if (!policy) return null;
const type = readEvidenceString(policy.type);
if (!type) return null;
if (type === "wake_owner") return "Corrective wake queued";
if (type === "board_escalation") return "Escalated to board";
if (type === "manual") return "Manual";
if (type === "monitor") {
const interval = readEvidenceString(policy.intervalLabel);
return interval ? `Monitor scheduled · ${interval}` : "Monitor scheduled";
}
return type.replaceAll("_", " ");
}
function formatTimeShort(value: string | Date | null | undefined): string | null {
if (!value) return null;
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
const now = Date.now();
const diffMs = date.getTime() - now;
const absMin = Math.round(Math.abs(diffMs) / 60_000);
if (absMin < 60) {
return diffMs >= 0 ? `in ${absMin}m` : `${absMin}m ago`;
}
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return null;
}
}
function shortenRunId(runId: string | null | undefined) {
if (!runId) return null;
if (runId.length <= 12) return runId;
return runId.slice(0, 8);
}
function MetadataRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0 px-3 py-1.5 text-xs sm:px-4">
<dt className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{label}
</dt>
<dd className="min-w-0 break-words text-foreground/90">{children}</dd>
</div>
);
}
function MissingValue() {
return <span className="text-muted-foreground">—</span>;
}
function AgentLink({
agentId,
agentMap,
fallback,
}: {
agentId: string | null | undefined;
agentMap?: ReadonlyMap<string, Agent>;
fallback?: string | null;
}) {
if (!agentId) {
return fallback ? <span>{fallback}</span> : <MissingValue />;
}
const agent = agentMap?.get(agentId);
const label = agent?.name ?? `agent ${agentId.slice(0, 8)}`;
if (agent) {
return (
<Link
to={agentUrl(agent)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{label}
</Link>
);
}
return <span className="font-medium">{label}</span>;
}
function RunChip({
runId,
agentId,
status,
}: {
runId: string | null;
agentId: string | null | undefined;
status?: string | null;
}) {
if (!runId) return <MissingValue />;
const short = shortenRunId(runId);
const inner = (
<>
<code className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
run {short}
</code>
{status ? (
<span className="font-sans text-[11px] text-muted-foreground">{status}</span>
) : null}
</>
);
if (agentId) {
return (
<Link
to={`/agents/${agentId}/runs/${runId}`}
className="inline-flex items-center gap-2 rounded-sm underline-offset-2 hover:underline"
>
{inner}
</Link>
);
}
return <span className="inline-flex items-center gap-2">{inner}</span>;
}
const RESOLVE_OPTIONS: Array<{
outcome: RecoveryResolveOutcome;
label: string;
description: string;
destructive?: boolean;
boardOnly?: boolean;
}> = [
{
outcome: "done",
label: "Mark issue done",
description: "Restore by recording the requested work as complete.",
},
{
outcome: "in_review",
label: "Send for review",
description: "Hand off to a reviewer with a real review path.",
},
{
outcome: "blocked",
label: "Mark blocked…",
description: "Block on a real follow-up issue.",
},
{
outcome: "delegated",
label: "Delegate follow-up issue…",
description: "Create a child issue and block this one on it.",
},
{
outcome: "false_positive",
label: "Mark false positive",
description: "Cancel recovery — only the board may do this.",
destructive: true,
boardOnly: true,
},
];
export function IssueRecoveryActionCard({
action,
agentMap,
forcedState,
onResolve,
canCancelRecovery = false,
className,
}: IssueRecoveryActionCardProps) {
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action);
const tone = STATE_TONE[cardState];
const ToneIcon = tone.Icon;
const headline = useMemo(() => {
if (cardState === "resolved" && action.outcome) {
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
}
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
}, [action.kind, action.outcome, cardState]);
const wakeSummary = readWakePolicySummary(action);
const evidenceSummary = pickEvidenceSummary(action);
const sourceRunId = readEvidenceRunId(action, "sourceRunId") ?? readEvidenceRunId(action, "latestRunId");
const correctiveRunId = readEvidenceRunId(action, "correctiveRunId");
const showAttempt = action.attemptCount > 1 && action.maxAttempts !== null;
const showTimeoutInline = (() => {
if (!action.timeoutAt) return false;
try {
const date = action.timeoutAt instanceof Date ? action.timeoutAt : new Date(action.timeoutAt);
const diffMs = date.getTime() - Date.now();
return diffMs > 0 && diffMs < 60 * 60 * 1000;
} catch {
return false;
}
})();
const updatedAtLabel = formatTimeShort(action.updatedAt);
const ariaState = ({
needed: "needed",
in_progress: "in progress",
observe_only: "observing active run",
escalated: "escalated",
resolved: "resolved",
} satisfies Record<RecoveryCardCardState, string>)[cardState];
const showResolveActions = onResolve !== undefined && cardState !== "resolved";
const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => {
if (option.boardOnly && !canCancelRecovery) return false;
return true;
});
return (
<section
role="status"
aria-label={`Recovery action: ${ariaState}`}
data-recovery-state={cardState}
data-recovery-kind={action.kind}
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
tone.containerClass,
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
tone.iconWrapClass,
)}
aria-hidden
>
<ToneIcon className={cn("h-4 w-4", tone.iconClass)} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className={tone.labelClass}>{tone.label}</span>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] tracking-normal text-muted-foreground">
{KIND_LABEL[action.kind] ?? action.kind}
</code>
{updatedAtLabel ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{updatedAtLabel}
</span>
</>
) : null}
</div>
<p className="mt-1 text-[14px] leading-6">{headline}</p>
</div>
</header>
<dl className={cn("border-t bg-background/40 dark:bg-background/20", tone.divider)}>
<MetadataRow label="Owner">
<span className="inline-flex flex-wrap items-center gap-1.5">
{action.ownerType === "agent" && action.ownerAgentId ? (
<>
<span className="text-muted-foreground">Recovery:</span>
<AgentLink agentId={action.ownerAgentId} agentMap={agentMap} />
</>
) : action.ownerType === "board" ? (
<span className="font-medium">Board</span>
) : action.ownerType === "user" && action.ownerUserId ? (
<span className="font-medium">user {action.ownerUserId.slice(0, 6)}</span>
) : action.ownerType === "system" ? (
<span className="font-medium">System</span>
) : (
<span className="text-muted-foreground">unassigned — pick one to wake them</span>
)}
{action.returnOwnerAgentId ? (
<>
<span className="text-muted-foreground">→ Returns to:</span>
<AgentLink agentId={action.returnOwnerAgentId} agentMap={agentMap} />
</>
) : null}
</span>
</MetadataRow>
<MetadataRow label="Source run">
<RunChip runId={sourceRunId} agentId={action.previousOwnerAgentId} />
</MetadataRow>
{correctiveRunId ? (
<MetadataRow label="Corrective run">
<RunChip runId={correctiveRunId} agentId={action.previousOwnerAgentId} />
</MetadataRow>
) : null}
<MetadataRow label="Evidence">
{evidenceSummary ? (
<span className="break-words font-mono text-[11px] text-foreground/80">{evidenceSummary}</span>
) : (
<MissingValue />
)}
</MetadataRow>
<MetadataRow label="Next action">
{action.nextAction ? <span>{action.nextAction}</span> : <MissingValue />}
</MetadataRow>
<MetadataRow label="Wake">
<span className="inline-flex flex-wrap items-center gap-1.5">
{wakeSummary ? <span>{wakeSummary}</span> : <MissingValue />}
{showAttempt ? (
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
attempt {action.attemptCount} of {action.maxAttempts}
</span>
) : null}
{showTimeoutInline ? (
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
Times out {formatTimeShort(action.timeoutAt) ?? "soon"}
</span>
) : null}
</span>
</MetadataRow>
{cardState === "resolved" && action.outcome ? (
<MetadataRow label="Resolution">
<span className={cn("font-medium", tone.labelClass)}>
Resolved as {OUTCOME_LABEL[action.outcome]}
{action.resolvedAt ? ` · ${formatTimeShort(action.resolvedAt) ?? ""}` : ""}
</span>
</MetadataRow>
) : null}
</dl>
{showResolveActions ? (
<div className={cn("flex flex-wrap items-center gap-2 border-t px-3 py-2.5 sm:px-4", tone.divider)}>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="default"
data-testid="recovery-action-resolve-trigger"
aria-label="Resolve recovery"
>
Resolve…
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="w-72 p-1.5"
>
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Resolve recovery
</div>
<div className="flex flex-col">
{visibleResolveOptions.map((option) => (
<button
key={option.outcome}
type="button"
onClick={() => onResolve?.(option.outcome)}
className={cn(
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
option.destructive ? "text-destructive" : null,
)}
>
<span className="font-medium leading-5">{option.label}</span>
<span className="text-[11px] leading-4 text-muted-foreground">{option.description}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
{cardState === "observe_only" ? (
<span className="text-[11px] text-muted-foreground">
Recovery is observing without interrupting the live run.
</span>
) : (
<span className="text-[11px] text-muted-foreground">
The card stays open until an explicit decision is recorded.
</span>
)}
</div>
) : null}
</section>
);
}
export type { IssueRecoveryActionStatus };
export default IssueRecoveryActionCard;
+66 -2
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Eye, Flag, X } from "lucide-react";
import { Eye, Flag, OctagonAlert, RefreshCw, TriangleAlert, X } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@@ -92,6 +92,8 @@ export function IssueRow({
Planning
</span>
) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
<span
data-testid="issue-row-parked-blocker"
@@ -125,6 +127,7 @@ export function IssueRow({
{productivityReviewIndicator}
{planningModeIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
@@ -151,6 +154,7 @@ export function IssueRow({
</span>
{planningModeIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
</>
)}
{mobileMeta ? (
@@ -230,3 +234,63 @@ export function IssueRow({
</Link>
);
}
const RECOVERY_CHIP_TONE: Record<
"needed" | "in_progress" | "observe_only" | "escalated",
{ className: string; icon: typeof TriangleAlert; label: string }
> = {
needed: {
className:
"border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
icon: TriangleAlert,
label: "Recovery needed",
},
in_progress: {
className:
"border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
icon: RefreshCw,
label: "Recovery in progress",
},
observe_only: {
className: "border-border bg-muted text-muted-foreground",
icon: Eye,
label: "Observing active run",
},
escalated: {
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
icon: OctagonAlert,
label: "Recovery escalated",
},
};
function rowRecoveryStateFor(action: IssueRecoveryAction): keyof typeof RECOVERY_CHIP_TONE | null {
if (action.status === "resolved" || action.status === "cancelled") return null;
if (action.status === "escalated") return "escalated";
if (action.kind === "active_run_watchdog") return "observe_only";
if (action.outcome === "delegated") return "in_progress";
return "needed";
}
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
const state = rowRecoveryStateFor(action);
if (!state) return null;
const tone = RECOVERY_CHIP_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="issue-row-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
className={cn(
"ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}
title={`${tone.label} — open the source issue to act.`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
+6
View File
@@ -46,6 +46,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.successful_run_handoff_required": "flagged missing next step on",
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
"issue.successful_run_handoff_escalated": "escalated missing next step on",
"issue.recovery_action_opened": "opened a recovery action on",
"issue.recovery_action_resolved": "resolved the recovery action on",
"issue.recovery_action_escalated": "escalated the recovery action on",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
@@ -98,6 +101,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.successful_run_handoff_required": "Run finished without a clear next step",
"issue.successful_run_handoff_resolved": "Next step chosen",
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
"issue.recovery_action_resolved": "Resolved the recovery action",
"issue.recovery_action_escalated": "Escalated the recovery action",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
+58
View File
@@ -598,6 +598,14 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canCancelRecoveryAction?: boolean;
legacyRecoverySourceIssue?: {
identifier: string | null;
href: string;
title?: string | null;
} | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -661,6 +669,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
blockerAttention,
successfulRunHandoff,
recoveryAction,
onResolveRecoveryAction,
canCancelRecoveryAction,
legacyRecoverySourceIssue,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -867,6 +879,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canCancelRecoveryAction={canCancelRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -2909,6 +2925,28 @@ export function IssueDetail() {
const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" });
}, [updateIssue.mutateAsync]);
const handleResolveRecoveryAction = useCallback(
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
switch (outcome) {
case "done":
void updateIssue.mutateAsync({ status: "done" });
return;
case "in_review":
void updateIssue.mutateAsync({ status: "in_review" });
return;
case "blocked":
void updateIssue.mutateAsync({ status: "blocked" });
return;
case "delegated":
if (issue) openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId));
return;
case "false_positive":
void updateIssue.mutateAsync({ status: "cancelled" });
return;
}
},
[currentUserId, issue, openNewIssue, updateIssue.mutateAsync],
);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -2970,6 +3008,22 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const legacyRecoverySourceIssue = (() => {
if (
issue.originKind !== "stranded_issue_recovery" &&
issue.originKind !== "stale_active_run_evaluation"
) {
return null;
}
const parent = ancestors.length > 0 ? ancestors[0] : null;
if (!parent) return null;
const ref = parent.identifier ?? parent.id;
return {
identifier: parent.identifier ?? null,
title: parent.title ?? null,
href: createIssueDetailPath(ref),
};
})();
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const files = evt.target.files;
if (!files || files.length === 0) return;
@@ -3787,6 +3841,10 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canCancelRecoveryAction={canManageTreeControl}
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
@@ -0,0 +1,201 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
import { IssueRow } from "@/components/IssueRow";
import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData";
const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!;
const codexAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("codex")) ?? storybookAgents[0]!;
function StoryFrame({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-5xl space-y-5">
<header>
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Source-issue recovery
</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
{description ? (
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
) : null}
</header>
{children}
</div>
</main>
);
}
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000aa",
companyId: "company-storybook",
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: claudeAgent.id,
ownerUserId: null,
previousOwnerAgentId: codexAgent.id,
returnOwnerAgentId: codexAgent.id,
cause: "missing_disposition",
fingerprint: "fp",
evidence: {
summary: "Run finished without picking a disposition. The PR has tests passing on CI.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
correctiveRunId: "2606404d-3859-4142-ba37-3228a037cc09",
},
nextAction: "Choose and record a valid issue disposition without copying transcript content.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: "2026-04-20T11:55:00.000Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-04-20T11:55:00.000Z",
updatedAt: "2026-04-20T11:55:00.000Z",
...overrides,
};
}
function CardPanel({ caption, action, forcedState, canCancelRecovery }: {
caption: string;
action: IssueRecoveryAction;
forcedState?: React.ComponentProps<typeof IssueRecoveryActionCard>["forcedState"];
canCancelRecovery?: boolean;
}) {
return (
<section className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{caption}
</div>
<IssueRecoveryActionCard
action={action}
agentMap={storybookAgentMap}
forcedState={forcedState}
onResolve={() => {}}
canCancelRecovery={canCancelRecovery}
/>
</section>
);
}
function AllStatesPanel() {
return (
<div className="grid gap-5 lg:grid-cols-1">
<CardPanel caption="State 1 · Recovery needed (default)" action={buildAction()} canCancelRecovery />
<CardPanel
caption="State 2 · Recovery in progress"
action={buildAction({ outcome: "delegated", attemptCount: 2 })}
forcedState="in_progress"
canCancelRecovery
/>
<CardPanel
caption="State 3 · Observing active run (watchdog)"
action={buildAction({
kind: "active_run_watchdog",
wakePolicy: { type: "monitor", intervalLabel: "in 4m" },
evidence: { summary: "The active run has been silent for 7 minutes. Last log: 'continuing checks…'" },
nextAction: "Observe the active run; intervene only if the silence persists past timeout.",
})}
/>
<CardPanel
caption="State 4 · Recovery escalated"
action={buildAction({
status: "escalated",
attemptCount: 3,
wakePolicy: { type: "board_escalation" },
evidence: {
summary: "Three corrective wakes failed. The recovery owner has not produced a disposition.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
nextAction: "Board operator: assign an invokable owner or record a manual resolution.",
})}
canCancelRecovery
/>
<CardPanel
caption="State 5 · Recovery resolved"
action={buildAction({
status: "resolved",
outcome: "restored",
resolvedAt: "2026-04-20T12:01:00.000Z",
nextAction: "Issue restored to a valid disposition.",
})}
/>
</div>
);
}
function InboxRowPanel() {
const baseIssue = createIssue();
return (
<div className="rounded-lg border border-border/70 bg-background/80">
<IssueRow
issue={{
...baseIssue,
identifier: "PAP-9065",
title: "Add full company search page",
status: "in_progress",
activeRecoveryAction: buildAction(),
}}
/>
<IssueRow
issue={{
...baseIssue,
id: "issue-recovery-watch",
identifier: "PAP-9099",
title: "Watchdog: PR review pipeline silent",
status: "in_progress",
activeRecoveryAction: buildAction({ kind: "active_run_watchdog" }),
}}
/>
<IssueRow
issue={{
...baseIssue,
id: "issue-recovery-escalated",
identifier: "PAP-9073",
title: "Recovery escalated for stranded run",
status: "blocked",
activeRecoveryAction: buildAction({ status: "escalated" }),
}}
/>
</div>
);
}
const meta = {
title: "Paperclip/Source Issue Recovery",
component: AllStatesPanel,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AllStatesPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const RecoveryActionCardStates: Story = {
render: () => (
<StoryFrame
title="Recovery action card states"
description="Five states required by the source-issue recovery contract: needed, in progress, observe-only watchdog, escalated, resolved."
>
<AllStatesPanel />
</StoryFrame>
),
};
export const InboxRowChips: Story = {
render: () => (
<StoryFrame
title="Inbox row recovery chips"
description="Source rows expose recovery state inline; no synthetic sibling row appears for source-scoped recovery."
>
<InboxRowPanel />
</StoryFrame>
),
};