Surface source recovery state on lists, blockers, and run panels

Phase 3B follow-up to the source-scoped recovery action backend (PAP-9132)
and UX (PAP-9133). Wires the recovery primitive into the surfaces named in
the approved plan so users see and resolve recovery from the source issue
without a duplicate sibling row.

- Project activeRecoveryAction onto every list-shaped issue payload:
  /companies/:companyId/issues and /agents/me/inbox-lite. The IssueRow
  recovery chip now shows wherever those endpoints feed (inbox, search,
  child lists), not just on the issue detail page.
- Extend IssueRelationIssueSummary with activeRecoveryAction and decorate
  blocker/blocks chips on /issues/:id and the heartbeat-context route. The
  IssueBlockedNotice chips inline a "Recovery needed/in progress/observing
  active run/escalated" indicator when the blocker has an active recovery
  action; bare blockers stay clean.
- Add a recovery indicator to the dashboard ActiveAgentsPanel run cards so
  live runs flag when their linked source issue has a recovery action open.
- Storybook: add BlockerNoticeRecoveryIndicators and
  ActiveRunPanelRecoveryChips stories, extend the screenshot script to
  cover them at desktop + mobile, light + dark.

Verification:
- pnpm --filter @paperclipai/{ui,shared,server} typecheck
- pnpm exec vitest run on IssueBlockedNotice/IssueRecoveryActionCard/IssueRow
  (4+9+11 = 24 tests pass; new test asserts the blocker recovery indicator)
- Storybook static rebuild + 16 fresh screenshots in
  tmp/pap-9134-recovery-screens/ (4 stories x 2 viewports x 2 themes)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-09 21:38:41 -05:00
1 parent dd1b5244e5
commit ed2c6382e3
8 files changed
+437 -16

No files matched your search

+1
View File
@@ -135,6 +135,7 @@ export interface IssueRelationIssueSummary {
assigneeAgentId: string | null;
assigneeUserId: string | null;
terminalBlockers?: IssueRelationIssueSummary[];
activeRecoveryAction?: IssueRecoveryAction | null;
}
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
+8
View File
@@ -26,6 +26,14 @@ const STORIES = [
id: "paperclip-source-issue-recovery--inbox-row-chips",
label: "inbox-rows",
},
{
id: "paperclip-source-issue-recovery--blocker-notice-recovery-indicators",
label: "blocker-notice",
},
{
id: "paperclip-source-issue-recovery--active-run-panel-recovery-chips",
label: "active-run-panel",
},
];
const THEMES = [
+8 -4
View File
@@ -42,6 +42,7 @@ import {
heartbeatService,
ISSUE_LIST_DEFAULT_LIMIT,
issueApprovalService,
issueRecoveryActionService,
issueService,
logActivity,
syncInstructionsBundleConfigFromFilePath,
@@ -1741,16 +1742,18 @@ export function agentRoutes(
}
const issuesSvc = issueService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
limit: ISSUE_LIST_DEFAULT_LIMIT,
});
const dependencyReadiness = await issuesSvc.listDependencyReadiness(
req.actor.companyId,
rows.map((issue) => issue.id),
);
const issueIds = rows.map((issue) => issue.id);
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
]);
res.json(
rows.map((issue) => ({
@@ -1764,6 +1767,7 @@ export function agentRoutes(
parentId: issue.parentId,
updatedAt: issue.updatedAt,
activeRun: issue.activeRun,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true,
unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0,
unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [],
+47 -5
View File
@@ -37,6 +37,7 @@ import {
type CompanySearchQuery,
type CompanySearchResponse,
type ExecutionWorkspace,
type IssueRelationIssueSummary,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
@@ -406,6 +407,36 @@ async function listSuccessfulRunHandoffStates(
return states;
}
type RecoveryActionsLister = {
listActiveForIssues: (
companyId: string,
sourceIssueIds: string[],
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
};
async function annotateRelationSummariesWithRecoveryActions(
recoveryActionsSvc: RecoveryActionsLister,
companyId: string,
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
) {
const candidates: IssueRelationIssueSummary[] = [];
const visit = (summary: IssueRelationIssueSummary) => {
candidates.push(summary);
for (const terminal of summary.terminalBlockers ?? []) {
visit(terminal);
}
};
for (const blocker of relations.blockedBy) visit(blocker);
for (const blocking of relations.blocks) visit(blocking);
if (candidates.length === 0) return;
const ids = [...new Set(candidates.map((summary) => summary.id))];
const map = await recoveryActionsSvc.listActiveForIssues(companyId, ids);
for (const summary of candidates) {
const action = map.get(summary.id) ?? null;
if (action) summary.activeRecoveryAction = action;
}
}
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
@@ -1449,14 +1480,15 @@ export function issueRoutes(
limit,
offset,
});
const handoffStates = await listSuccessfulRunHandoffStates(
db,
companyId,
result.map((issue) => issue.id),
);
const issueIds = result.map((issue) => issue.id);
const [handoffStates, recoveryActionByIssue] = await Promise.all([
listSuccessfulRunHandoffStates(db, companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
]);
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
})));
});
@@ -1559,6 +1591,11 @@ export function issueRoutes(
currentExecutionWorkspacePromise,
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]);
await annotateRelationSummariesWithRecoveryActions(
recoveryActionsSvc,
issue.companyId,
relations,
);
res.json({
issue: {
@@ -1668,6 +1705,11 @@ export function issueRoutes(
svc.getCurrentScheduledRetry(issue.id),
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]);
await annotateRelationSummariesWithRecoveryActions(
recoveryActionsSvc,
issue.companyId,
relations,
);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
+63 -2
View File
@@ -1,17 +1,73 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
import { Identity } from "./Identity";
import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
function runCardRecoveryStateFor(action: IssueRecoveryAction): RunCardRecoveryState | 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";
}
const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
needed: {
icon: TriangleAlert,
label: "Recovery needed",
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
in_progress: {
icon: RefreshCw,
label: "Recovery in progress",
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
observe_only: {
icon: Eye,
label: "Observing active run",
className: "border-border bg-muted text-muted-foreground",
},
escalated: {
icon: OctagonAlert,
label: "Recovery escalated",
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = runCardRecoveryStateFor(action);
if (!state) return null;
const tone = RUN_CARD_RECOVERY_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source issue to act.`}
className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4;
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
@@ -189,6 +245,11 @@ const AgentRunCard = memo(function AgentRunCard({
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
</Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
</div>
) : null}
</div>
)}
</div>
+67 -2
View File
@@ -2,8 +2,10 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
@@ -27,11 +29,20 @@ afterEach(() => {
container = null;
});
function withProviders(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
return (
<MemoryRouter>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</MemoryRouter>
);
}
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
act(() => root?.render(withProviders(element)));
return container;
}
@@ -102,4 +113,58 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toBe("");
});
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-1",
companyId: "co-1",
sourceIssueId: "blocker-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_state",
fingerprint: "fp-1",
evidence: {},
nextAction: "choose disposition",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.textContent).toContain("Recovery needed");
});
});
+62 -2
View File
@@ -1,10 +1,68 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle, Flag } from "lucide-react";
import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, Eye, Flag, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
type BlockerRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
function blockerRecoveryStateFor(action: IssueRecoveryAction): BlockerRecoveryState | 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";
}
const BLOCKER_RECOVERY_TONE: Record<BlockerRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
needed: {
icon: TriangleAlert,
label: "Recovery needed",
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
in_progress: {
icon: RefreshCw,
label: "Recovery in progress",
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
observe_only: {
icon: Eye,
label: "Observing active run",
className: "border-border bg-muted text-muted-foreground",
},
escalated: {
icon: OctagonAlert,
label: "Recovery escalated",
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
const state = blockerRecoveryStateFor(action);
if (!state) return null;
const tone = BLOCKER_RECOVERY_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="issue-blocked-notice-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source issue to act.`}
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
export function IssueBlockedNotice({
issueStatus,
blockers,
@@ -69,6 +127,7 @@ export function IssueBlockedNotice({
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
const recoveryAction = blocker.activeRecoveryAction ?? null;
return (
<IssueLinkQuicklook
key={blocker.id}
@@ -80,6 +139,7 @@ export function IssueBlockedNotice({
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
</IssueLinkQuicklook>
);
};
@@ -1,8 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import type { IssueRecoveryAction, IssueRelationIssueSummary } from "@paperclipai/shared";
import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
import { IssueRow } from "@/components/IssueRow";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData";
const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!;
@@ -131,6 +133,162 @@ function AllStatesPanel() {
);
}
function buildBlocker(
overrides: Partial<IssueRelationIssueSummary> = {},
): IssueRelationIssueSummary {
return {
id: "blocker-1",
identifier: "PAP-9065",
title: "Add full company search page",
status: "in_progress",
priority: "medium",
assigneeAgentId: claudeAgent.id,
assigneeUserId: null,
...overrides,
};
}
function BlockerNoticePanel() {
return (
<div className="space-y-4">
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
buildBlocker({ activeRecoveryAction: buildAction() }),
buildBlocker({
id: "blocker-2",
identifier: "PAP-9099",
title: "Watchdog: PR review pipeline silent",
activeRecoveryAction: buildAction({ kind: "active_run_watchdog" }),
}),
buildBlocker({
id: "blocker-3",
identifier: "PAP-9073",
title: "Recovery escalated for stranded run",
status: "blocked",
activeRecoveryAction: buildAction({ status: "escalated" }),
}),
buildBlocker({
id: "blocker-4",
identifier: "PAP-9051",
title: "Bare blocker without recovery state",
}),
]}
/>
</div>
);
}
type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
needed: {
icon: TriangleAlert,
label: "Recovery needed",
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
in_progress: {
icon: RefreshCw,
label: "Recovery in progress",
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
observe_only: {
icon: Eye,
label: "Observing active run",
className: "border-border bg-muted text-muted-foreground",
},
escalated: {
icon: OctagonAlert,
label: "Recovery escalated",
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function ActiveRunRecoveryChip({ state }: { state: RunCardRecoveryState }) {
const tone = RUN_CARD_RECOVERY_TONE[state];
const Icon = tone.icon;
return (
<span
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
role="status"
aria-label={tone.label}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
function ActiveRunCardMock({
identifier,
title,
recoveryState,
}: {
identifier: string;
title: string;
recoveryState: RunCardRecoveryState;
}) {
return (
<div className="flex h-[260px] w-full max-w-[320px] flex-col overflow-hidden rounded-xl border border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]">
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
</span>
<span className="text-sm font-medium">CodexCoder</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>Live now</span>
</div>
</div>
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
<ExternalLink className="h-2.5 w-2.5" />
</span>
</div>
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<span className="line-clamp-2 text-cyan-700 dark:text-cyan-300">
{identifier} - {title}
</span>
<div className="mt-1.5">
<ActiveRunRecoveryChip state={recoveryState} />
</div>
</div>
</div>
<div className="flex-1 px-3 py-2 text-[11px] text-muted-foreground">Live transcript…</div>
</div>
);
}
function ActiveRunPanel() {
return (
<div className="grid gap-4 sm:grid-cols-2">
<ActiveRunCardMock
identifier="PAP-9065"
title="Add full company search page"
recoveryState="needed"
/>
<ActiveRunCardMock
identifier="PAP-9099"
title="Watchdog: PR review pipeline silent"
recoveryState="observe_only"
/>
<ActiveRunCardMock
identifier="PAP-9073"
title="Recovery escalated for stranded run"
recoveryState="escalated"
/>
<ActiveRunCardMock
identifier="PAP-9101"
title="Recovery in progress: delegated"
recoveryState="in_progress"
/>
</div>
);
}
function InboxRowPanel() {
const baseIssue = createIssue();
return (
@@ -199,3 +357,25 @@ export const InboxRowChips: Story = {
</StoryFrame>
),
};
export const BlockerNoticeRecoveryIndicators: Story = {
render: () => (
<StoryFrame
title="Blocker notice recovery indicators"
description="Blocker chips inline a recovery indicator when the blocker has an active recovery action. Plain blockers stay clean."
>
<BlockerNoticePanel />
</StoryFrame>
),
};
export const ActiveRunPanelRecoveryChips: Story = {
render: () => (
<StoryFrame
title="Active run panel recovery chips"
description="Active run cards on the dashboard expose recovery state on the linked source issue."
>
<ActiveRunPanel />
</StoryFrame>
),
};