From ed2c6382e36472f1828e55288dd268a50e7a6416 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 20:46:49 -0500 Subject: [PATCH] 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 --- packages/shared/src/types/issue.ts | 1 + scripts/screenshot-recovery-card.cjs | 8 + server/src/routes/agents.ts | 12 +- server/src/routes/issues.ts | 52 ++++- ui/src/components/ActiveAgentsPanel.tsx | 65 ++++++- ui/src/components/IssueBlockedNotice.test.tsx | 69 ++++++- ui/src/components/IssueBlockedNotice.tsx | 64 +++++- .../stories/source-issue-recovery.stories.tsx | 182 +++++++++++++++++- 8 files changed, 437 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 495eee829d3..065b86e6a5b 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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"; diff --git a/scripts/screenshot-recovery-card.cjs b/scripts/screenshot-recovery-card.cjs index acc875c4334..6b2b0a27fb5 100644 --- a/scripts/screenshot-recovery-card.cjs +++ b/scripts/screenshot-recovery-card.cjs @@ -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 = [ diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d1b6d78cb96..03f6ef58ee5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 ?? [], diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a6245fb4768..017b8d05325 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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>>; +}; + +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) : []; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 905c2147ee0..f031920a244 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -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 = { + 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 ( + + + {tone.label} + + ); +} + 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}` : ""} + {issue?.activeRecoveryAction ? ( +
+ +
+ ) : null} )} diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 725974bc36d..4dad91b1a89 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -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 ( + + {node} + + ); +} + 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( + , + ); + + 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"); + }); }); diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index 2fa83d38928..56630c2bbed 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -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 = { + 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 ( + + + {tone.label} + + ); +} + 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 ( {blocker.title} + {recoveryAction ? : null} ); }; diff --git a/ui/storybook/stories/source-issue-recovery.stories.tsx b/ui/storybook/stories/source-issue-recovery.stories.tsx index 137289f11a6..7ab97c9967d 100644 --- a/ui/storybook/stories/source-issue-recovery.stories.tsx +++ b/ui/storybook/stories/source-issue-recovery.stories.tsx @@ -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 { + 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 ( +
+ +
+ ); +} + +type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated"; + +const RUN_CARD_RECOVERY_TONE: Record = { + 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 ( + + + {tone.label} + + ); +} + +function ActiveRunCardMock({ + identifier, + title, + recoveryState, +}: { + identifier: string; + title: string; + recoveryState: RunCardRecoveryState; +}) { + return ( +
+
+
+
+
+ + + + + CodexCoder +
+
+ Live now +
+
+ + + +
+
+ + {identifier} - {title} + +
+ +
+
+
+
Live transcript…
+
+ ); +} + +function ActiveRunPanel() { + return ( +
+ + + + +
+ ); +} + function InboxRowPanel() { const baseIssue = createIssue(); return ( @@ -199,3 +357,25 @@ export const InboxRowChips: Story = { ), }; + +export const BlockerNoticeRecoveryIndicators: Story = { + render: () => ( + + + + ), +}; + +export const ActiveRunPanelRecoveryChips: Story = { + render: () => ( + + + + ), +};