[codex] Add source-scoped recovery actions (#5599)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies, where work must end with a clear disposition rather than ambiguous agent liveness. > - Recovery currently detects stalled or missing-next-step issues, but source issue recovery can become split across child recovery issues, blockers, and comments. > - That makes it harder for operators and agents to see who owns recovery and what exact action is needed on the original issue. > - Source-scoped recovery actions give the original issue a first-class active recovery state with owner, evidence, wake policy, and resolution outcome. > - This pull request adds the recovery-action data model, backend reconciliation and resolution APIs, and board UI indicators/actions. > - The benefit is clearer stalled-work recovery without losing source issue context or relying on comments as the liveness path. ## What Changed - Added the `issue_recovery_actions` schema, shared types/constants/validators, and an idempotent `0084_issue_recovery_actions` migration ordered after current `master` migrations. - Updated stranded/missing-disposition recovery to create source-scoped recovery actions, wake the recovery owner on the source issue, and avoid locking the source issue for recovery-action wakes. - Added API support for reading active recovery actions on issue detail/list surfaces and resolving them with restored, blocked, cancelled, or false-positive outcomes. - Require blocked recovery resolutions to have an unresolved first-class blocker, and removed the UI shortcut that could mark recovery blocked without a blocker selection path. - Surfaced recovery indicators/actions in the issue UI, blocker notices, active run panels, issue rows, and Storybook coverage. - Updated docs and focused tests for recovery semantics, ownership, races, stale comments, and UI behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/components/IssueBlockedNotice.test.tsx ui/src/api/issues.test.ts` — 5 files, 72 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/db typecheck` — passed, including migration numbering check. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Follow-up verification after blocker-resolution guard: `pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/api/issues.test.ts` — 3 files, 27 tests passed. - Follow-up `pnpm --filter @paperclipai/server typecheck` — passed. - Follow-up `pnpm --filter @paperclipai/ui typecheck` — passed. - UI states are available in `ui/storybook/stories/source-issue-recovery.stories.tsx`; screenshot capture helper is `scripts/screenshot-recovery-card.cjs`. ## Risks - Medium: recovery behavior changes from child recovery issue ownership toward source-scoped actions, so operators may see stalled-work state in new places. - Migration risk is mitigated by using the next migration slot after `master` and making the table/constraints/index creation idempotent for anyone who previously applied the old branch-local `0082_dizzy_master_mold` migration. - Existing child recovery issue paths are still guarded for already-created recovery issues, but new source-scoped flows should be watched in CI and Greptile review. > 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 coding agent, tool use enabled for shell, Git, GitHub, and local test execution. Context window 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] 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] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
@@ -13,7 +14,9 @@ import { issuesApi } from "./issues";
|
||||
describe("issuesApi.list", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.post.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
mockApi.post.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("passes parentId through to the company issues endpoint", async () => {
|
||||
@@ -47,4 +50,21 @@ describe("issuesApi.list", () => {
|
||||
"/companies/company-1/issues?limit=500&offset=1500",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "done",
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/issues/issue-1/recovery-actions/resolve",
|
||||
{
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "done",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueRecoveryAction,
|
||||
IssueRetryNowResponse,
|
||||
IssueThreadInteraction,
|
||||
IssueTreeControlPreview,
|
||||
@@ -27,6 +28,11 @@ export type IssueUpdateResponse = Issue & {
|
||||
comment?: IssueComment | null;
|
||||
};
|
||||
|
||||
export type ResolveRecoveryActionResponse = {
|
||||
issue: Issue;
|
||||
recoveryAction: IssueRecoveryAction;
|
||||
};
|
||||
|
||||
export const issuesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
@@ -94,6 +100,15 @@ export const issuesApi = {
|
||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||
resolveRecoveryAction: (
|
||||
id: string,
|
||||
data: {
|
||||
actionId?: string;
|
||||
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
sourceIssueStatus: "done" | "in_review" | "blocked";
|
||||
resolutionNote?: string | null;
|
||||
},
|
||||
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
|
||||
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
|
||||
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
|
||||
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
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 {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
} from "../lib/recovery-display";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { RunChatSurface } from "./RunChatSurface";
|
||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
|
||||
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_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 +216,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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
} from "../lib/recovery-display";
|
||||
|
||||
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_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,
|
||||
@@ -69,6 +98,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 +110,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
canFalsePositiveRecoveryAction?: 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,
|
||||
canFalsePositiveRecoveryAction = 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}
|
||||
canFalsePositive={canFalsePositiveRecoveryAction}
|
||||
/>
|
||||
) : 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,218 @@
|
||||
// @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;
|
||||
}
|
||||
|
||||
function click(element: Element | null) {
|
||||
if (!element) throw new Error("Expected element to exist");
|
||||
act(() => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
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).not.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("calls resolve with done and does not offer delegated recovery", () => {
|
||||
const onResolve = vi.fn();
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
|
||||
expect(document.body.textContent).toContain("Mark issue done");
|
||||
expect(document.body.textContent).not.toContain("Mark blocked");
|
||||
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
|
||||
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
|
||||
|
||||
expect(onResolve).toHaveBeenCalledWith("done");
|
||||
});
|
||||
|
||||
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} canFalsePositive />,
|
||||
);
|
||||
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
|
||||
expect(document.body.textContent).toContain("Mark issue done");
|
||||
expect(document.body.textContent).toContain("Send for review");
|
||||
expect(document.body.textContent).toContain("False positive, done");
|
||||
expect(document.body.textContent).toContain("False positive, review");
|
||||
expect(document.body.textContent).not.toContain("Mark blocked");
|
||||
});
|
||||
|
||||
it("hides false-positive options unless canFalsePositive is set", () => {
|
||||
const first = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
|
||||
);
|
||||
click(first.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.textContent).not.toContain("False positive");
|
||||
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
|
||||
const onResolve = vi.fn();
|
||||
const second = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canFalsePositive />,
|
||||
);
|
||||
click(second.querySelector("[data-testid='recovery-action-resolve-trigger']"));
|
||||
expect(document.body.textContent).toContain("False positive, done");
|
||||
expect(document.body.textContent).toContain("False positive, review");
|
||||
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("False positive, done")) ?? null);
|
||||
expect(onResolve).toHaveBeenCalledWith("false_positive_done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
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";
|
||||
import {
|
||||
deriveRecoveryDisplayState,
|
||||
type RecoveryDisplayState,
|
||||
} from "@/lib/recovery-display";
|
||||
|
||||
export type RecoveryCardCardState = RecoveryDisplayState;
|
||||
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
|
||||
|
||||
export type RecoveryResolveOutcome =
|
||||
| "done"
|
||||
| "in_review"
|
||||
| "false_positive_done"
|
||||
| "false_positive_in_review";
|
||||
|
||||
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. false-positive dismissal). */
|
||||
canFalsePositive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||||
missing_disposition: "Missing Disposition",
|
||||
stranded_assigned_issue: "Stranded Issue",
|
||||
active_run_watchdog: "Active Watchdog",
|
||||
issue_graph_liveness: "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",
|
||||
},
|
||||
};
|
||||
|
||||
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: "false_positive_done",
|
||||
label: "False positive, done",
|
||||
description: "Dismiss recovery and mark the source issue complete.",
|
||||
destructive: true,
|
||||
boardOnly: true,
|
||||
},
|
||||
{
|
||||
outcome: "false_positive_in_review",
|
||||
label: "False positive, review",
|
||||
description: "Dismiss recovery and send the source issue for review.",
|
||||
destructive: true,
|
||||
boardOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function IssueRecoveryActionCard({
|
||||
action,
|
||||
agentMap,
|
||||
forcedState,
|
||||
onResolve,
|
||||
canFalsePositive = 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 && !canFalsePositive) 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;
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { deriveActiveRecoveryDisplayState, RECOVERY_CHIP_DEFAULT_TONE } from "../lib/recovery-display";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
@@ -92,6 +93,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 +128,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 +155,7 @@ export function IssueRow({
|
||||
</span>
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
{recoveryIndicator}
|
||||
</>
|
||||
)}
|
||||
{mobileMeta ? (
|
||||
@@ -230,3 +235,27 @@ export function IssueRow({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
|
||||
const state = deriveActiveRecoveryDisplayState(action);
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { Eye, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
|
||||
export type RecoveryDisplayState =
|
||||
| "needed"
|
||||
| "in_progress"
|
||||
| "observe_only"
|
||||
| "escalated"
|
||||
| "resolved";
|
||||
|
||||
export type ActiveRecoveryDisplayState = Exclude<RecoveryDisplayState, "resolved">;
|
||||
|
||||
export const RECOVERY_CHIP_DEFAULT_TONE: Record<
|
||||
ActiveRecoveryDisplayState,
|
||||
{ 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",
|
||||
},
|
||||
};
|
||||
|
||||
export function deriveRecoveryDisplayState(
|
||||
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
|
||||
): RecoveryDisplayState {
|
||||
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";
|
||||
}
|
||||
|
||||
export function deriveActiveRecoveryDisplayState(
|
||||
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
|
||||
): ActiveRecoveryDisplayState | null {
|
||||
const state = deriveRecoveryDisplayState(action);
|
||||
return state === "resolved" ? null : state;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@pape
|
||||
import { act, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueDetail } from "./IssueDetail";
|
||||
import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
@@ -1447,3 +1447,39 @@ describe("IssueDetail", () => {
|
||||
expect(footer?.className).toContain("bg-background");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canBoardResolveRecoveryAction", () => {
|
||||
it("falls back to companyIds when memberships are not populated", () => {
|
||||
expect(
|
||||
canBoardResolveRecoveryAction("company-1", {
|
||||
companyIds: ["company-1"],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
source: "session",
|
||||
keyId: null,
|
||||
user: null,
|
||||
userId: "user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses populated memberships as the authoritative board access source", () => {
|
||||
expect(
|
||||
canBoardResolveRecoveryAction("company-1", {
|
||||
companyIds: ["company-1"],
|
||||
memberships: [
|
||||
{
|
||||
companyId: "company-1",
|
||||
membershipRole: "viewer",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
isInstanceAdmin: false,
|
||||
source: "session",
|
||||
keyId: null,
|
||||
user: null,
|
||||
userId: "user-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi, type RunForIssue } from "../api/activity";
|
||||
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { accessApi } from "../api/access";
|
||||
import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -157,6 +157,7 @@ import {
|
||||
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
||||
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
@@ -211,6 +212,23 @@ function treeControlPreviewErrorCopy(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unable to load preview.";
|
||||
}
|
||||
|
||||
export function canBoardResolveRecoveryAction(
|
||||
companyId: string | null | undefined,
|
||||
boardAccess: CurrentBoardAccess | undefined,
|
||||
) {
|
||||
if (!companyId || !boardAccess) return false;
|
||||
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
||||
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
|
||||
return boardAccess.companyIds.includes(companyId);
|
||||
}
|
||||
|
||||
const membership = boardAccess.memberships.find(
|
||||
(item) => item.companyId === companyId && item.status === "active",
|
||||
);
|
||||
if (!membership) return false;
|
||||
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||
}
|
||||
|
||||
function resolveRunningIssueRun(
|
||||
activeRun: ActiveRunForIssue | null | undefined,
|
||||
liveRuns: readonly LiveRunForIssue[] | undefined,
|
||||
@@ -598,6 +616,14 @@ type IssueDetailChatTabProps = {
|
||||
blockedBy: Issue["blockedBy"];
|
||||
blockerAttention: Issue["blockerAttention"] | null;
|
||||
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
|
||||
recoveryAction: Issue["activeRecoveryAction"];
|
||||
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
||||
canFalsePositiveRecoveryAction?: boolean;
|
||||
legacyRecoverySourceIssue?: {
|
||||
identifier: string | null;
|
||||
href: string;
|
||||
title?: string | null;
|
||||
} | null;
|
||||
comments: IssueDetailComment[];
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
interactions: IssueThreadInteraction[];
|
||||
@@ -661,6 +687,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
blockedBy,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
recoveryAction,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction,
|
||||
legacyRecoverySourceIssue,
|
||||
comments,
|
||||
locallyQueuedCommentRunIds,
|
||||
interactions,
|
||||
@@ -867,6 +897,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
blockedBy={blockedBy ?? []}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={successfulRunHandoff}
|
||||
recoveryAction={recoveryAction ?? null}
|
||||
onResolveRecoveryAction={onResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
@@ -1374,6 +1408,7 @@ export function IssueDetail() {
|
||||
selectedCompanyId
|
||||
&& boardAccess?.companyIds?.includes(selectedCompanyId),
|
||||
);
|
||||
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
|
||||
const { data: feedbackVotes } = useQuery({
|
||||
queryKey: queryKeys.issues.feedbackVotes(issueId!),
|
||||
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
|
||||
@@ -1709,6 +1744,34 @@ export function IssueDetail() {
|
||||
}
|
||||
},
|
||||
});
|
||||
const resolveRecoveryAction = useMutation({
|
||||
mutationFn: (data: {
|
||||
actionId?: string;
|
||||
outcome: ResolveRecoveryActionOutcome;
|
||||
sourceIssueStatus: "done" | "in_review" | "blocked";
|
||||
resolutionNote?: string | null;
|
||||
}) => issuesApi.resolveRecoveryAction(issueId!, data),
|
||||
onSuccess: ({ issue: nextIssue }) => {
|
||||
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
|
||||
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
|
||||
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
invalidateIssueCollections();
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Recovery resolution failed",
|
||||
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
const executeTreeControl = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (treeControlMode === "resume") {
|
||||
@@ -2909,6 +2972,28 @@ export function IssueDetail() {
|
||||
const handleResumeFromBacklog = useCallback(async () => {
|
||||
await updateIssue.mutateAsync({ status: "todo" });
|
||||
}, [updateIssue.mutateAsync]);
|
||||
const activeRecoveryActionId = issue?.activeRecoveryAction?.id;
|
||||
const handleResolveRecoveryAction = useCallback(
|
||||
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
|
||||
const actionId = activeRecoveryActionId;
|
||||
if (!actionId) return;
|
||||
switch (outcome) {
|
||||
case "done":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
|
||||
return;
|
||||
case "in_review":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
|
||||
return;
|
||||
case "false_positive_done":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" });
|
||||
return;
|
||||
case "false_positive_in_review":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" });
|
||||
return;
|
||||
}
|
||||
},
|
||||
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync],
|
||||
);
|
||||
|
||||
const treePreviewAffectedIssues = useMemo(
|
||||
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
||||
@@ -2970,6 +3055,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 +3888,10 @@ export function IssueDetail() {
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
blockerAttention={issue.blockerAttention ?? null}
|
||||
successfulRunHandoff={issue.successfulRunHandoff ?? null}
|
||||
recoveryAction={issue.activeRecoveryAction ?? null}
|
||||
onResolveRecoveryAction={handleResolveRecoveryAction}
|
||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||
comments={threadComments}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
interactions={interactions}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactNode } from "react";
|
||||
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]!;
|
||||
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, canFalsePositive }: {
|
||||
caption: string;
|
||||
action: IssueRecoveryAction;
|
||||
forcedState?: React.ComponentProps<typeof IssueRecoveryActionCard>["forcedState"];
|
||||
canFalsePositive?: 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={() => {}}
|
||||
canFalsePositive={canFalsePositive}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AllStatesPanel() {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-1">
|
||||
<CardPanel caption="State 1 · Recovery needed (default)" action={buildAction()} canFalsePositive />
|
||||
<CardPanel
|
||||
caption="State 2 · Recovery in progress"
|
||||
action={buildAction({ outcome: "delegated", attemptCount: 2 })}
|
||||
forcedState="in_progress"
|
||||
canFalsePositive
|
||||
/>
|
||||
<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.",
|
||||
})}
|
||||
canFalsePositive
|
||||
/>
|
||||
<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 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 (
|
||||
<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>
|
||||
),
|
||||
};
|
||||
|
||||
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>
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user