Files
paperclip/ui/src/components/IssueRecoveryActionCard.tsx
T
Dotta d734bd43d1 [codex] Roll up May 17 branch changes (#6210)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> 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-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## 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
- [ ] 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>
2026-05-17 17:15:06 -05:00

544 lines
19 KiB
TypeScript

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 =
| "todo"
| "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: "todo",
label: "Try again",
description: "Dismiss recovery and return the source issue to todo.",
},
{
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;