[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:
@@ -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;
|
||||
Reference in New Issue
Block a user