feat(ui): render liveness-aware recovery as a recessed in-progress state

Recovery surfaces classified nearly every active action as `needed`, so a
task showed a prominent amber "Recovery needed" warning even while a
recovery owner was already running it.

Make the display derivation liveness-aware. `deriveRecoveryDisplayState`
now takes optional `RecoveryLivenessSignals` (recovery-owner run, source
live run, queued wake, live continuation) and resolves the five display
states against them:

- a live/queued run owned by the recovery owner is `in_progress`, which
  outranks escalation — the escalation already found an owner
- a newer durable source-owner run folds the action to `observe_only`
  rather than leaving a stale call to action
- prominent amber/red is kept only for `needed` (no owner/path) and
  `escalated` (a decision is genuinely required)

Recessed states render as `RecoveryProgressLine` — an expandable
"Recovery in progress · CTO" row that reveals the existing recovery card
on toggle, so evidence and resolve actions stay inspectable without a
second prominent surface competing for attention.

Applied across the recovery card, issue rows, active-agents panel,
blocker indicators, blocked inbox chips, the workspace recovery surface,
and both the classic and chat-style task threads.

Verified: 349 focused UI tests pass across 16 affected suites, tsc clean,
and `pnpm check:token-gates` reports all three gates clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed 2026-08-15 19:19:38 +00:00
1 parent 38752c4e5e
commit 610bd160a8
20 files changed
+1515 -53

No files matched your search

+29 -6
View File
@@ -9,6 +9,8 @@ import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
recoveryChipLabel,
recoveryLivenessFromIssue,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
import { ExternalLink } from "lucide-react";
@@ -18,25 +20,46 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Badge } from "@/components/ui/badge";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
function RunCardRecoveryChip({
action,
issue,
run,
}: {
action: IssueRecoveryAction;
issue: Issue | undefined;
run: LiveRunForIssue;
}) {
// The card is already rendering the run, so it is the strongest liveness
// evidence available here: a running/queued run owned by the recovery owner
// means recovery is in progress, not needed.
const state = deriveActiveRecoveryDisplayState(
action,
recoveryLivenessFromIssue(issue, {
ownerRun: { agentId: run.agentId, status: run.status },
}),
);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
const label = recoveryChipLabel(state, action.kind, { ownerName: run.agentName });
return (
<Badge variant="outline"
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source task to act.`}
aria-label={label}
title={
tone.recessed
? `${label} — open the source task to inspect it.`
: `${label} — open the source task to act.`
}
className={cn(
"gap-0.5 px-1.5 text-(length:--text-nano)",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
{label}
</Badge>
);
}
@@ -230,7 +253,7 @@ const AgentRunCard = memo(function AgentRunCard({
</Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
<RunCardRecoveryChip action={issue.activeRecoveryAction} issue={issue} run={run} />
</div>
) : null}
</div>
+21 -1
View File
@@ -18,6 +18,11 @@ import {
type BlockedInboxSort,
} from "../lib/blockedInbox";
import { BlockedReasonChip } from "./BlockedReasonChip";
import {
deriveRecoveryDisplayState,
isRecessedRecoveryState,
recoveryLivenessFromIssue,
} from "../lib/recovery-display";
import { IssueGroupHeader } from "./IssueGroupHeader";
import { IssueRow } from "./IssueRow";
import { Identity } from "./Identity";
@@ -302,10 +307,23 @@ function BlockedInboxRow({
}: BlockedInboxRowProps) {
const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById);
const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt);
const isLive = liveIssueIds.has(row.issue.id);
const blockerAttention = resolveInboxIssueBlockerAttention(row.issue, {
isLive: liveIssueIds.has(row.issue.id),
isLive,
loadedSubtreeLiveCount: subtreeLiveCounts.get(row.issue.id) ?? 0,
});
// An open recovery issue whose owner is already live/queued is progress, not a
// request for the operator — the chip drops to its recessed variant.
const recoveryState = row.issue.activeRecoveryAction
? deriveRecoveryDisplayState(
row.issue.activeRecoveryAction,
recoveryLivenessFromIssue(row.issue, {
sourceHasLiveRun: isLive || Boolean(row.issue.executionRunId),
sourceLiveRunAgentId: row.issue.assigneeAgentId ?? null,
}),
)
: null;
const recoveryRecessed = recoveryState !== null && isRecessedRecoveryState(recoveryState);
const desktopTrailing = (
<span className="flex shrink-0 items-center gap-3 text-xs">
@@ -316,6 +334,7 @@ function BlockedInboxRow({
<BlockedReasonChip
reason={row.attention.reason}
severity={row.attention.severity}
recoveryRecessed={recoveryRecessed}
className="max-w-full"
/>
</span>
@@ -377,6 +396,7 @@ function BlockedInboxRow({
<BlockedReasonChip
reason={row.attention.reason}
severity={row.attention.severity}
recoveryRecessed={recoveryRecessed}
className="ml-2 max-w-(--sz-12rem) align-middle sm:hidden"
/>
}
@@ -82,4 +82,29 @@ describe("BlockedReasonChip", () => {
root.unmount();
});
});
it("recesses an open recovery issue whose owner is already running", () => {
const root = createRoot(container);
act(() => {
root.render(
<BlockedReasonChip reason="open_recovery_issue" severity="medium" recoveryRecessed />,
);
});
const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
expect(chip?.getAttribute("data-variant")).toBe("recovery_in_progress");
expect(chip?.textContent).toContain("Recovery in progress");
expect(chip?.className).not.toMatch(/amber|red|cyan/);
});
it("keeps the recovery-required treatment when no owner is running", () => {
const root = createRoot(container);
act(() => {
root.render(<BlockedReasonChip reason="open_recovery_issue" severity="medium" />);
});
const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
expect(chip?.getAttribute("data-variant")).toBe("recovery_required");
expect(chip?.textContent).toContain("Recovery required");
});
});
+7 -2
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, Clock, Pause, User, Wrench } from "lucide-react";
import { AlertTriangle, Clock, Pause, RefreshCw, User, Wrench } from "lucide-react";
import type { ComponentType } from "react";
import type { IssueBlockedInboxSeverity } from "@paperclipai/shared";
import { cn } from "../lib/utils";
@@ -12,6 +12,8 @@ import type { IssueBlockedInboxReason } from "@paperclipai/shared";
interface BlockedReasonChipProps {
reason: IssueBlockedInboxReason;
severity: IssueBlockedInboxSeverity;
/** Recovery is already owned by a live/queued runner — render the recessed variant. */
recoveryRecessed?: boolean;
compact?: boolean;
className?: string;
}
@@ -23,6 +25,7 @@ const VARIANT_STYLES: Record<BlockedReasonVariant, string> = {
"border-violet-300/70 bg-violet-50 text-violet-800 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
recovery_required:
"border-cyan-300/70 bg-cyan-50 text-cyan-800 dark:border-cyan-500/30 dark:bg-cyan-500/10 dark:text-cyan-300",
recovery_in_progress: "border-border bg-muted text-muted-foreground",
stalled:
"border-amber-400/70 bg-amber-100 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200",
needs_attention:
@@ -36,6 +39,7 @@ const VARIANT_STYLES: Record<BlockedReasonVariant, string> = {
const VARIANT_ICONS: Record<BlockedReasonVariant, IconComponent> = {
needs_decision: Clock,
recovery_required: Wrench,
recovery_in_progress: RefreshCw,
stalled: AlertTriangle,
needs_attention: AlertTriangle,
external_wait: User,
@@ -50,10 +54,11 @@ const SEVERITY_DOT: Partial<Record<IssueBlockedInboxSeverity, string>> = {
export function BlockedReasonChip({
reason,
severity,
recoveryRecessed = false,
compact = false,
className,
}: BlockedReasonChipProps) {
const variant = blockedReasonVariant(reason);
const variant = blockedReasonVariant(reason, { recoveryRecessed });
const label = blockedVariantLabel(variant);
const Icon = VARIANT_ICONS[variant];
const dotClass = SEVERITY_DOT[severity];
@@ -646,6 +646,62 @@ describe("IssueBlockedNotice", () => {
expect(indicator?.textContent).toContain("Recovery needed");
});
it("recesses the blocker recovery indicator while the blocker itself is live", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["blocker-1"])}
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-cto",
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?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(indicator?.textContent).toContain("Recovery in progress");
expect(indicator?.textContent).not.toContain("Recovery needed");
expect(indicator?.className).not.toMatch(/amber|red/);
});
it("labels a workspace_validation blocker recovery distinctly", () => {
const node = render(
<IssueBlockedNotice
+19 -3
View File
@@ -22,11 +22,18 @@ import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
recoveryChipLabel,
type RecoveryLivenessSignals,
} from "../lib/recovery-display";
import { StatusGlyph } from "./StatusGlyph";
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
function BlockerRecoveryIndicator({
action,
signals,
}: {
action: IssueRecoveryAction;
signals?: RecoveryLivenessSignals;
}) {
const state = deriveActiveRecoveryDisplayState(action, signals);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
@@ -480,6 +487,13 @@ export function IssueBlockedNotice({
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
const recoveryAction = blocker.activeRecoveryAction ?? null;
// A blocker summary carries no run fields, so its liveness comes from the
// live-issue set the thread already resolved.
const blockerIsLive = (liveIssueIds ?? EMPTY_LIVE_IDS).has(blocker.id);
const recoverySignals: RecoveryLivenessSignals = {
sourceHasLiveRun: blockerIsLive,
sourceLiveRunAgentId: blockerIsLive ? blocker.assigneeAgentId ?? null : null,
};
return (
<IssueLinkQuicklook
key={blocker.id}
@@ -491,7 +505,9 @@ export function IssueBlockedNotice({
<span className="max-w-(--sz-18rem) truncate font-sans text-(length:--text-micro) text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
{recoveryAction ? (
<BlockerRecoveryIndicator action={recoveryAction} signals={recoverySignals} />
) : null}
</IssueLinkQuicklook>
);
};
@@ -3869,4 +3869,92 @@ describe("IssueChatThread", () => {
avatarUrl: "/avatars/alice.png",
});
});
it("renders a live recovery owner as a recessed line and keeps the card when nothing is live", () => {
const recoveryAction = {
id: "recovery-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "stranded_assigned_issue",
status: "active",
ownerType: "agent",
ownerAgentId: "cto-agent",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "stranded_assigned_issue",
fingerprint: "fp",
evidence: {},
nextAction: "Choose what happens next.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-08-11T00:00:00.000Z",
updatedAt: "2026-08-11T00:00:00.000Z",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
const renderThread = (liveRuns: unknown[]) => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
liveRuns={liveRuns as any}
issueId="issue-1"
issueStatus="in_progress"
recoveryAction={recoveryAction}
onResolveRecoveryAction={() => {}}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const line = container.querySelector('[data-testid="recovery-progress-line"]');
const card = container.querySelector("[data-recovery-state][role='status']");
const result = {
lineState: line?.getAttribute("data-recovery-state") ?? null,
cardState: card?.getAttribute("data-recovery-state") ?? null,
text: container.textContent ?? "",
};
act(() => {
root.unmount();
});
return result;
};
const live = renderThread([
{
id: "run-1",
status: "running",
agentId: "cto-agent",
agentName: "CTO",
createdAt: "2026-08-11T00:00:00.000Z",
startedAt: "2026-08-11T00:00:00.000Z",
finishedAt: null,
issueId: "issue-1",
},
]);
expect(live.lineState).toBe("in_progress");
expect(live.cardState).toBeNull();
expect(live.text).toContain("Recovery in progress \u00b7 CTO");
expect(live.text).not.toContain("RECOVERY NEEDED");
const idle = renderThread([]);
expect(idle.lineState).toBeNull();
expect(idle.cardState).toBe("needed");
expect(idle.text).toContain("RECOVERY NEEDED");
});
});
+65 -15
View File
@@ -185,6 +185,8 @@ import {
type RecoveryReissueRequest,
type RecoveryResolveOutcome,
} from "./IssueRecoveryActionCard";
import { RecoveryProgressLine } from "./RecoveryProgressLine";
import { deriveRecoveryPresentation } from "../lib/recovery-display";
import { SourceTrustBadge } from "./SourceTrustBadge";
import { CommentAttributionChip } from "./CommentAttributionChip";
import { resolveCommentAttribution } from "../lib/comment-attribution";
@@ -4503,6 +4505,33 @@ export function IssueChatThread({
const liveNow = activeRunIds.size > 0 || Boolean(issueId && liveIssueIds?.has(issueId));
return liveNow ? { ...successfulRunHandoff, hasLiveContinuation: true } : successfulRunHandoff;
}, [successfulRunHandoff, activeRunIds, issueId, liveIssueIds]);
// Liveness-aware recovery presentation: a recovery owner that is already live
// or queued reads as recessed progress, and a newer source-owner run folds the
// action down to observation. Only a genuinely ownerless or escalated action
// keeps the prominent card.
const recoveryDisplay = useMemo(
() =>
deriveRecoveryPresentation({
action: recoveryAction,
liveRuns: displayLiveRuns,
issueId,
liveIssueIds,
issueAssigneeAgentId,
scheduledRetry,
successfulRunHandoff: successfulRunHandoffWithLiveness,
resolveAgentName: (agentId) => agentMap?.get(agentId)?.name,
}),
[
agentMap,
displayLiveRuns,
issueAssigneeAgentId,
issueId,
liveIssueIds,
recoveryAction,
scheduledRetry,
successfulRunHandoffWithLiveness,
],
);
const clearLatestSettleTimeouts = useCallback(() => {
for (const timeout of latestSettleTimeoutsRef.current) {
window.clearTimeout(timeout);
@@ -5102,21 +5131,42 @@ export function IssueChatThread({
onResume={onResumeFromBacklog}
resuming={resumeFromBacklogPending}
/>
{recoveryAction ? (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
onReissueIsolated={onReissueIsolatedRecoveryAction}
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
{recoveryAction && recoveryDisplay ? (
recoveryDisplay.state === "in_progress" ||
recoveryDisplay.state === "observe_only" ? (
<RecoveryProgressLine
state={recoveryDisplay.state}
ownerName={recoveryDisplay.ownerName}
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
onReissueIsolated={onReissueIsolatedRecoveryAction}
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
) : (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
forcedState={recoveryDisplay.state}
onResolve={onResolveRecoveryAction}
onReissueIsolated={onReissueIsolatedRecoveryAction}
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
)
) : null}
{legacyRecoverySourceIssue ? (
<SystemNotice
+26 -6
View File
@@ -157,15 +157,17 @@ const STATE_TONE: Record<RecoveryCardCardState, {
Icon: TriangleAlert,
divider: "border-amber-300/60 dark:border-amber-500/30",
},
// Recessed like `observe_only`: an owner is already live or queued, so the card
// reports progress rather than demanding a decision.
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",
"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: RefreshCw,
divider: "border-sky-300/60 dark:border-sky-500/30",
divider: "border-border/70",
},
observe_only: {
label: "OBSERVING ACTIVE RUN",
@@ -919,12 +921,25 @@ export function IssueRecoveryActionCard({
const ToneIcon = tone.Icon;
const divergence = useMemo(() => readWorkspaceDivergence(action), [action]);
const ownerName = action.ownerAgentId ? agentMap?.get(action.ownerAgentId)?.name ?? null : null;
const headline = useMemo(() => {
if (cardState === "resolved" && action.outcome) {
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
}
// The kind headlines all read as calls to action ("choose what happens
// next"). Once an owner is live or a newer run took over, that framing is
// wrong — report what is happening instead.
if (cardState === "in_progress") {
return ownerName
? `Recovery is in progress — ${ownerName} is working this task's next step.`
: "Recovery is in progress — an owner is already working this task's next step.";
}
if (cardState === "observe_only" && action.kind !== "active_run_watchdog") {
return "A newer run is live on this task. Recovery is observing without interrupting it.";
}
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
}, [action.kind, action.outcome, cardState]);
}, [action.kind, action.outcome, cardState, ownerName]);
const wakeSummary = readWakePolicySummary(action);
const evidenceSummary = pickEvidenceSummary(action);
@@ -1274,6 +1289,11 @@ export function IssueRecoveryActionCard({
<span className="text-(length:--text-micro) text-muted-foreground">
Recovery is observing without interrupting the live run.
</span>
) : cardState === "in_progress" ? (
<span className="text-(length:--text-micro) text-muted-foreground">
{ownerName ? `${ownerName} is on it` : "An owner is on it"} — resolve only to
override the in-flight recovery.
</span>
) : (
<span className="text-(length:--text-micro) text-muted-foreground">
The card stays open until an explicit decision is recorded.
+96
View File
@@ -503,6 +503,102 @@ describe("IssueRow", () => {
});
});
describe("recovery indicator", () => {
const recoveryAction = {
id: "recovery-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "stranded_assigned_issue",
status: "active",
ownerType: "agent",
ownerAgentId: "cto-agent",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "stranded_assigned_issue",
fingerprint: "fp",
evidence: {},
nextAction: "Choose what happens next.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: new Date("2026-08-11T00:00:00.000Z"),
updatedAt: new Date("2026-08-11T00:00:00.000Z"),
} as unknown as NonNullable<Issue["activeRecoveryAction"]>;
function renderRow(issue: Issue) {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={issue} />);
});
const chip = container.querySelector('[data-testid="issue-row-recovery-indicator"]');
const result = { state: chip?.getAttribute("data-recovery-state"), text: chip?.textContent };
act(() => {
root.unmount();
});
return result;
}
it("warns when no owner is running the recovery", () => {
expect(
renderRow(createIssue({ activeRecoveryAction: recoveryAction })),
).toEqual({ state: "needed", text: "Recovery needed" });
});
it("recesses to in-progress while the recovery owner holds the execution lock", () => {
expect(
renderRow(
createIssue({
status: "in_progress",
executionRunId: "run-1",
assigneeAgentId: "cto-agent",
activeRecoveryAction: recoveryAction,
}),
),
).toEqual({ state: "in_progress", text: "Recovery in progress" });
});
it("recesses to observation when a different owner is live on the task", () => {
expect(
renderRow(
createIssue({
status: "in_progress",
executionRunId: "run-1",
assigneeAgentId: "coder-agent",
activeRecoveryAction: recoveryAction,
}),
),
).toEqual({ state: "observe_only", text: "Observing active run" });
});
it("recesses to in-progress while a wake is queued", () => {
expect(
renderRow(
createIssue({
activeRecoveryAction: recoveryAction,
scheduledRetry: {
runId: "run-2",
status: "scheduled_retry",
agentId: "cto-agent",
agentName: "CTO",
retryOfRunId: null,
scheduledRetryAt: null,
scheduledRetryAttempt: 1,
scheduledRetryReason: null,
},
}),
),
).toEqual({ state: "in_progress", text: "Recovery in progress" });
});
});
it("keeps the hover wash on the row root while the overlay link stays a bare positioning layer", () => {
const root = createRoot(container);
+11 -3
View File
@@ -10,8 +10,10 @@ import {
import { cn } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
recoveryLivenessFromIssue,
RECOVERY_CHIP_DEFAULT_TONE,
recoveryChipLabel,
type RecoveryLivenessSignals,
} from "../lib/recovery-display";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
@@ -149,7 +151,9 @@ export function IssueRow({
</span>
) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null;
const recoveryIndicator = recoveryAction
? renderRecoveryChip(recoveryAction, selected, recoveryLivenessFromIssue(issue))
: null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
<Badge variant="outline"
data-testid="issue-row-parked-blocker"
@@ -335,8 +339,12 @@ export function IssueRow({
);
}
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
const state = deriveActiveRecoveryDisplayState(action);
function renderRecoveryChip(
action: IssueRecoveryAction,
selected: boolean,
signals: RecoveryLivenessSignals,
): ReactNode {
const state = deriveActiveRecoveryDisplayState(action, signals);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
import { vi } from "vitest";
import { RecoveryProgressLine } from "./RecoveryProgressLine";
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;
function act<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
return result as T;
}
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 ctoAgent = {
id: "33333333-3333-3333-3333-333333333333",
companyId: "company-1",
name: "CTO",
role: "cto",
status: "working",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
urlKey: "cto",
} as unknown as Agent;
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000ab",
companyId: "company-1",
sourceIssueId: "00000000-0000-0000-0000-0000000000fe",
recoveryIssueId: null,
kind: "stranded_assigned_issue",
status: "active",
ownerType: "agent",
ownerAgentId: ctoAgent.id,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "stranded_assigned_issue",
fingerprint: "fp",
evidence: { summary: "The last run stopped without a disposition." },
nextAction: "Choose what happens next.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-08-11T10:00:00.000Z",
updatedAt: "2026-08-11T10:00:00.000Z",
...overrides,
};
}
const agentMap = new Map<string, Agent>([[ctoAgent.id, ctoAgent]]);
describe("RecoveryProgressLine", () => {
it("renders a recessed line naming the live recovery owner", () => {
const host = render(
<RecoveryProgressLine state="in_progress" action={buildAction()} agentMap={agentMap} />,
);
const line = host.querySelector('[data-testid="recovery-progress-line"]');
expect(line?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(host.textContent).toContain("Recovery in progress · CTO");
expect(host.textContent).not.toContain("Recovery needed");
});
it("prefers an explicitly supplied owner name over the agent map", () => {
const host = render(
<RecoveryProgressLine
state="in_progress"
ownerName="Ops"
action={buildAction()}
agentMap={agentMap}
/>,
);
expect(host.textContent).toContain("Recovery in progress · Ops");
});
it("labels the observe-only state without an owner suffix", () => {
const host = render(
<RecoveryProgressLine state="observe_only" action={buildAction()} agentMap={agentMap} />,
);
expect(host.textContent).toContain("Observing active run");
expect(host.textContent).not.toContain("· CTO");
});
it("keeps the details folded until the line is toggled", () => {
const host = render(
<RecoveryProgressLine state="in_progress" action={buildAction()} agentMap={agentMap} />,
);
const toggle = host.querySelector('[data-testid="recovery-progress-line-toggle"]');
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(host.querySelector('[data-testid="recovery-progress-line-details"]')).toBeNull();
click(toggle);
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
const details = host.querySelector('[data-testid="recovery-progress-line-details"]');
expect(details).not.toBeNull();
// Evidence stays inspectable — exactly one recovery card, in the recessed tone.
const cards = host.querySelectorAll("[data-recovery-state][role='status']");
expect(cards.length).toBe(1);
expect(cards[0]?.getAttribute("data-recovery-state")).toBe("in_progress");
click(toggle);
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(host.querySelector('[data-testid="recovery-progress-line-details"]')).toBeNull();
});
it("opens expanded when asked", () => {
const host = render(
<RecoveryProgressLine
state="in_progress"
defaultOpen
action={buildAction()}
agentMap={agentMap}
/>,
);
expect(host.querySelector('[data-testid="recovery-progress-line-details"]')).not.toBeNull();
});
it("exposes the disclosure to the keyboard as a native, focusable button", () => {
const host = render(
<RecoveryProgressLine state="in_progress" action={buildAction()} agentMap={agentMap} />,
);
const toggle = host.querySelector<HTMLButtonElement>(
'[data-testid="recovery-progress-line-toggle"]',
);
expect(toggle?.tagName).toBe("BUTTON");
expect(toggle?.getAttribute("type")).toBe("button");
// aria-controls must point at the panel it reveals, once revealed.
const controls = toggle?.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(toggle?.className).toContain("focus-visible:ring-2");
act(() => toggle?.focus());
expect(document.activeElement).toBe(toggle);
click(toggle);
const panel = host.querySelector('[data-testid="recovery-progress-line-details"]');
expect(panel?.id).toBe(controls);
});
it("uses theme tokens instead of prominent amber/red treatment", () => {
const host = render(
<RecoveryProgressLine
state="in_progress"
defaultOpen
action={buildAction()}
agentMap={agentMap}
/>,
);
const toggle = host.querySelector('[data-testid="recovery-progress-line-toggle"]');
expect(toggle?.className).toContain("text-muted-foreground");
expect(toggle?.className).not.toMatch(/amber|red|sky/);
const card = host.querySelector("[data-recovery-state][role='status']");
// Light and dark both resolve from the muted token pair — no per-theme literals.
expect(card?.className).not.toMatch(/amber|red|sky/);
expect(card?.className).toContain("bg-muted/40");
expect(card?.className).toContain("dark:bg-muted/20");
});
it("stays legible on narrow viewports", () => {
const host = render(
<RecoveryProgressLine state="in_progress" action={buildAction()} agentMap={agentMap} />,
);
const toggle = host.querySelector('[data-testid="recovery-progress-line-toggle"]');
expect(toggle?.className).toContain("min-w-0");
const label = toggle?.querySelector("span");
expect(label?.className).toContain("truncate");
// The "Details" hint is desktop-only so the owner name keeps the mobile row.
const hint = toggle?.querySelectorAll("span")[1];
expect(hint?.className).toContain("hidden");
expect(hint?.className).toContain("sm:inline");
});
});
@@ -0,0 +1,90 @@
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
RECOVERY_CHIP_DEFAULT_TONE,
recoveryChipLabel,
type ActiveRecoveryDisplayState,
} from "@/lib/recovery-display";
import {
IssueRecoveryActionCard,
type IssueRecoveryActionCardProps,
} from "./IssueRecoveryActionCard";
export interface RecoveryProgressLineProps
extends Omit<IssueRecoveryActionCardProps, "forcedState" | "className"> {
/** The recessed state this line represents (`in_progress` or `observe_only`). */
state: Extract<ActiveRecoveryDisplayState, "in_progress" | "observe_only">;
/** Display name of the live/queued recovery owner, e.g. "CTO". */
ownerName?: string | null;
/** Force the details open on first render (tests, deep links). */
defaultOpen?: boolean;
className?: string;
}
/**
* Recovery presented as a recessed, expandable line — "Recovery in progress · CTO".
*
* Used wherever a recovery owner is already live or queued, or where a newer
* source-owner run has folded the action down to observation. Nothing is hidden:
* expanding reveals the same `IssueRecoveryActionCard` (kept in its recessed
* tone via `forcedState`), so the evidence and resolve actions stay inspectable
* without a second prominent recovery surface competing for attention.
*/
export function RecoveryProgressLine({
state,
ownerName,
defaultOpen = false,
className,
...cardProps
}: RecoveryProgressLineProps) {
const [open, setOpen] = useState(defaultOpen);
const detailsId = useId();
const { action, agentMap } = cardProps;
const resolvedOwnerName =
ownerName ?? (action.ownerAgentId ? agentMap?.get(action.ownerAgentId)?.name ?? null : null);
const label = recoveryChipLabel(state, action.kind, { ownerName: resolvedOwnerName });
const Icon = RECOVERY_CHIP_DEFAULT_TONE[state].icon;
return (
<div
data-testid="recovery-progress-line"
data-recovery-state={state}
data-recovery-kind={action.kind}
className={cn("flex w-full min-w-0 flex-col", className)}
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={detailsId}
data-testid="recovery-progress-line-toggle"
className={cn(
"flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-left text-xs text-muted-foreground",
"hover:bg-muted/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate font-medium">{label}</span>
<span className="hidden shrink-0 text-muted-foreground/70 sm:inline">
· {open ? "Hide details" : "Details"}
</span>
<ChevronDown
className={cn(
"ml-auto h-3.5 w-3.5 shrink-0 transition-transform duration-150",
open && "rotate-180",
)}
aria-hidden
/>
</button>
{open ? (
<div id={detailsId} data-testid="recovery-progress-line-details" className="mt-1.5">
<IssueRecoveryActionCard {...cardProps} forcedState={state} />
</div>
) : null}
</div>
);
}
export default RecoveryProgressLine;
@@ -7,6 +7,10 @@ import { executionWorkspacesApi } from "../api/execution-workspaces";
import { accessApi } from "../api/access";
import { queryKeys } from "../lib/queryKeys";
import { useToastActions } from "../context/ToastContext";
import {
deriveRecoveryDisplayState,
recoveryLivenessFromIssue,
} from "@/lib/recovery-display";
import {
IssueRecoveryActionCard,
type RecoveryReissueRequest,
@@ -252,6 +256,14 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
if (!isWorkspaceValidationFailure || !issueId) return null;
if (!recoveryAction || recoveryAction.kind !== "workspace_validation") return null;
// The failed run is history. If the source task has since gone live again —
// the recovery owner picked it up, or a newer owner took over — the divergence
// is already being handled, so this surface reports instead of warning.
const displayState = deriveRecoveryDisplayState(
recoveryAction,
recoveryLivenessFromIssue(issue),
);
return (
<div className="space-y-2" data-testid="run-workspace-recovery-surface">
<div className="flex items-center justify-between gap-2">
@@ -271,6 +283,7 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
</div>
<IssueRecoveryActionCard
action={recoveryAction}
forcedState={displayState}
variant="compact"
onResolve={handleResolve}
onReissueIsolated={handleReissue}
+96
View File
@@ -294,3 +294,99 @@ describe("TaskChatThread live transcript", () => {
expect(container.textContent).toContain("Worked");
});
});
describe("TaskChatThread recovery presentation", () => {
const recoveryAction = {
id: "recovery-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "stranded_assigned_issue",
status: "active",
ownerType: "agent",
ownerAgentId: "cto-agent",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "stranded_assigned_issue",
fingerprint: "fp",
evidence: {},
nextAction: "Choose what happens next.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-08-11T00:00:00.000Z",
updatedAt: "2026-08-11T00:00:00.000Z",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
const liveOwnerRun = {
id: "run-1",
status: "running",
agentId: "cto-agent",
agentName: "CTO",
createdAt: "2026-08-11T00:00:00.000Z",
startedAt: "2026-08-11T00:00:00.000Z",
finishedAt: null,
issueId: "issue-1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
it("renders a live recovery owner as a recessed, expandable line", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueId="issue-1"
issueStatus="in_progress"
recoveryAction={recoveryAction}
liveRuns={[liveOwnerRun]}
onResolveRecoveryAction={() => {}}
/>,
);
const line = container.querySelector('[data-testid="recovery-progress-line"]');
expect(line).not.toBeNull();
expect(line?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(container.textContent).toContain("Recovery in progress · CTO");
expect(container.textContent).not.toContain("Recovery needed");
// Folded by default — no second, prominent recovery surface.
expect(container.querySelector('[data-testid="recovery-progress-line-details"]')).toBeNull();
const toggle = container.querySelector('[data-testid="recovery-progress-line-toggle"]');
flushSync(() => {
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('[data-testid="recovery-progress-line-details"]')).not.toBeNull();
expect(container.querySelectorAll("[data-recovery-state][role='status']").length).toBe(1);
});
it("keeps the prominent card when no owner is live or queued", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueId="issue-1"
issueStatus="todo"
recoveryAction={recoveryAction}
onResolveRecoveryAction={() => {}}
/>,
);
expect(container.querySelector('[data-testid="recovery-progress-line"]')).toBeNull();
const card = container.querySelector("[data-recovery-state][role='status']");
expect(card?.getAttribute("data-recovery-state")).toBe("needed");
expect(container.textContent).toContain("RECOVERY NEEDED");
});
it("renders nothing when there is no recovery action", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="issue-1" />);
expect(container.querySelector('[data-testid="task-chat-recovery-notice"]')).toBeNull();
});
});
+92
View File
@@ -40,6 +40,9 @@ import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/T
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
import { RecoveryProgressLine } from "@/components/RecoveryProgressLine";
import { deriveRecoveryPresentation } from "@/lib/recovery-display";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
@@ -126,8 +129,58 @@ export function TaskChatThread(props: TaskChatThreadProps) {
draftKey,
onInterruptQueued,
interruptingQueuedRunId,
liveIssueIds,
scheduledRetry,
successfulRunHandoff,
recoveryAction,
onResolveRecoveryAction,
onReissueIsolatedRecoveryAction,
reissueIsolatedRecoveryActionPending,
onReconcileForwardRecoveryAction,
onBreakGlassOverrideRecoveryAction,
onQuarantineRestoreRecoveryAction,
quarantineRestoreRecoveryActionPending,
canBreakGlassRecoveryAction,
reconcileRecoveryActionPending,
canFalsePositiveRecoveryAction,
} = props;
// Recovery in the chat shell. A live or queued recovery owner renders as the
// same recessed one-liner the classic thread uses (in the register of
// TaskChatSystemNotice), so the chat never shouts "Recovery needed" over an
// owner who is already working. Only an ownerless or escalated action gets the
// prominent card.
const recoveryDisplay = useMemo(
() =>
deriveRecoveryPresentation({
action: recoveryAction,
liveRuns: [
...(liveRuns ?? []).filter((run) => isLiveIssueRun(run, issueStatus)),
...(activeRun && isLiveIssueRun(activeRun, issueStatus)
? [{ agentId: activeRun.agentId, agentName: activeRun.agentName, status: activeRun.status }]
: []),
],
issueId,
liveIssueIds,
issueAssigneeAgentId,
scheduledRetry,
successfulRunHandoff,
resolveAgentName: (agentId) => agentMap?.get(agentId)?.name,
}),
[
activeRun,
agentMap,
issueAssigneeAgentId,
issueId,
issueStatus,
liveIssueIds,
liveRuns,
recoveryAction,
scheduledRetry,
successfulRunHandoff,
],
);
const linkedRunMetaById = useMemo(() => {
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[number]>();
for (const run of linkedRuns ?? []) map.set(run.runId, run);
@@ -655,6 +708,45 @@ export function TaskChatThread(props: TaskChatThreadProps) {
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:w-(--pct-80)",
)}
>
{recoveryAction && recoveryDisplay ? (
<div data-testid="task-chat-recovery-notice">
{recoveryDisplay.state === "in_progress" ||
recoveryDisplay.state === "observe_only" ? (
<RecoveryProgressLine
state={recoveryDisplay.state}
ownerName={recoveryDisplay.ownerName}
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
onReissueIsolated={onReissueIsolatedRecoveryAction}
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
) : (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
forcedState={recoveryDisplay.state}
onResolve={onResolveRecoveryAction}
onReissueIsolated={onReissueIsolatedRecoveryAction}
reissuePending={reissueIsolatedRecoveryActionPending}
onReconcileForward={onReconcileForwardRecoveryAction}
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
)}
</div>
) : null}
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
+17 -1
View File
@@ -10,6 +10,7 @@ export type BlockedReasonVariant =
| "stalled"
| "needs_attention"
| "recovery_required"
| "recovery_in_progress"
| "external_wait"
| "owner_paused";
@@ -33,6 +34,7 @@ export const BLOCKED_REASON_VARIANT_ORDER: BlockedReasonVariant[] = [
"stalled",
"needs_attention",
"recovery_required",
"recovery_in_progress",
"external_wait",
"owner_paused",
];
@@ -42,6 +44,7 @@ export const BLOCKED_VARIANT_LABELS: Record<BlockedReasonVariant, string> = {
stalled: "Blocked chain stalled",
needs_attention: "Needs attention",
recovery_required: "Recovery required",
recovery_in_progress: "Recovery in progress",
external_wait: "External wait",
owner_paused: "Owner paused",
};
@@ -70,7 +73,20 @@ const SEVERITY_RANK: Record<IssueBlockedInboxSeverity, number> = {
export type BlockedInboxBadgeTone = "muted" | "amber" | "red";
export function blockedReasonVariant(reason: IssueBlockedInboxReason): BlockedReasonVariant {
export function blockedReasonVariant(
reason: IssueBlockedInboxReason,
options?: {
/**
* The task's recovery action is already owned by a live/queued runner (or has
* been folded to observation). An open recovery issue in that state is
* progress, not an operator request, so it drops to the recessed variant.
*/
recoveryRecessed?: boolean;
},
): BlockedReasonVariant {
if (reason === "open_recovery_issue" && options?.recoveryRecessed) {
return "recovery_in_progress";
}
return VARIANT_BY_REASON[reason] ?? "needs_attention";
}
+270 -6
View File
@@ -2,9 +2,24 @@ import { describe, expect, it } from "vitest";
import {
deriveActiveRecoveryDisplayState,
deriveRecoveryDisplayState,
deriveRecoveryPresentation,
isRecessedRecoveryState,
recoveryChipLabel,
recoveryLivenessFromIssue,
RECOVERY_CHIP_DEFAULT_TONE,
} from "./recovery-display";
const CTO_ID = "cto-agent-id";
const CODER_ID = "coder-agent-id";
const base = {
status: "active" as const,
kind: "missing_disposition" as const,
outcome: null,
ownerAgentId: CTO_ID,
recoveryIssueId: null,
};
describe("recoveryChipLabel", () => {
it("returns the workspace-specific label when kind is workspace_validation and state is needed", () => {
expect(recoveryChipLabel("needed", "workspace_validation")).toBe(
@@ -29,15 +44,52 @@ describe("recoveryChipLabel", () => {
"Observing active run",
);
});
it("names the live recovery owner on the in-progress line", () => {
expect(recoveryChipLabel("in_progress", "missing_disposition", { ownerName: "CTO" })).toBe(
"Recovery in progress · CTO",
);
});
it("ignores an owner name for states that are not in progress", () => {
expect(recoveryChipLabel("needed", "missing_disposition", { ownerName: "CTO" })).toBe(
"Recovery needed",
);
expect(recoveryChipLabel("escalated", "missing_disposition", { ownerName: "CTO" })).toBe(
"Recovery escalated",
);
});
it("ignores a blank owner name", () => {
expect(recoveryChipLabel("in_progress", "missing_disposition", { ownerName: " " })).toBe(
"Recovery in progress",
);
});
});
describe("recovery tones", () => {
it("keeps prominent amber/red only for needed and escalated", () => {
expect(RECOVERY_CHIP_DEFAULT_TONE.needed.recessed).toBe(false);
expect(RECOVERY_CHIP_DEFAULT_TONE.escalated.recessed).toBe(false);
expect(RECOVERY_CHIP_DEFAULT_TONE.in_progress.recessed).toBe(true);
expect(RECOVERY_CHIP_DEFAULT_TONE.observe_only.recessed).toBe(true);
});
it("gives the in-progress chip no amber or red treatment", () => {
expect(RECOVERY_CHIP_DEFAULT_TONE.in_progress.className).not.toMatch(/amber|red/);
expect(RECOVERY_CHIP_DEFAULT_TONE.observe_only.className).not.toMatch(/amber|red/);
});
it("classifies recessed states", () => {
expect(isRecessedRecoveryState("in_progress")).toBe(true);
expect(isRecessedRecoveryState("observe_only")).toBe(true);
expect(isRecessedRecoveryState("resolved")).toBe(true);
expect(isRecessedRecoveryState("needed")).toBe(false);
expect(isRecessedRecoveryState("escalated")).toBe(false);
});
});
describe("deriveRecoveryDisplayState", () => {
const base = {
status: "active" as const,
kind: "missing_disposition" as const,
outcome: null,
};
it("classifies workspace_validation active as needed", () => {
expect(deriveRecoveryDisplayState({ ...base, kind: "workspace_validation" })).toBe(
"needed",
@@ -46,4 +98,216 @@ describe("deriveRecoveryDisplayState", () => {
deriveActiveRecoveryDisplayState({ ...base, kind: "workspace_validation" }),
).toBe("needed");
});
it("keeps needed when nothing is live or queued", () => {
expect(deriveRecoveryDisplayState(base, {})).toBe("needed");
});
it("prefers the server-projected display state when present", () => {
expect(
deriveRecoveryDisplayState({ ...base, displayState: "observe_only" } as never, {
sourceHasLiveRun: false,
}),
).toBe("observe_only");
});
it("ignores an unrecognized server-projected display state", () => {
expect(deriveRecoveryDisplayState({ ...base, displayState: "bogus" } as never)).toBe("needed");
});
it("reads a running recovery-owner run as in progress", () => {
expect(
deriveRecoveryDisplayState(base, {
ownerRun: { agentId: CTO_ID, status: "running" },
}),
).toBe("in_progress");
});
it("reads a queued recovery-owner run as in progress", () => {
expect(
deriveRecoveryDisplayState(base, {
ownerRun: { agentId: CTO_ID, status: "queued" },
}),
).toBe("in_progress");
});
it("does not treat a finished owner run as liveness", () => {
expect(
deriveRecoveryDisplayState(base, {
ownerRun: { agentId: CTO_ID, status: "completed" },
}),
).toBe("needed");
});
it("does not credit another agent's live run to the recovery owner", () => {
expect(
deriveRecoveryDisplayState(base, {
ownerRun: { agentId: CODER_ID, status: "running" },
}),
).toBe("needed");
});
// PAP-16986: recovery escalated to the CTO while the CTO was already running.
it("shows an escalated action with a live owner run as in progress, not escalated", () => {
expect(
deriveRecoveryDisplayState(
{ ...base, status: "escalated" },
{ ownerRun: { agentId: CTO_ID, status: "running" } },
),
).toBe("in_progress");
});
it("keeps escalated prominent when no owner is live", () => {
expect(deriveRecoveryDisplayState({ ...base, status: "escalated" }, {})).toBe("escalated");
});
it("reads a live source run owned by the recovery owner as in progress", () => {
expect(
deriveRecoveryDisplayState(base, {
sourceHasLiveRun: true,
sourceLiveRunAgentId: CTO_ID,
}),
).toBe("in_progress");
});
it("folds the action to observation when a different owner took the task over", () => {
expect(
deriveRecoveryDisplayState(base, {
sourceHasLiveRun: true,
sourceLiveRunAgentId: CODER_ID,
}),
).toBe("observe_only");
});
it("reads a queued wake as in progress", () => {
expect(deriveRecoveryDisplayState(base, { hasQueuedWake: true })).toBe("in_progress");
});
it("reads a delegated outcome or a recovery issue as in progress", () => {
expect(deriveRecoveryDisplayState({ ...base, outcome: "delegated" })).toBe("in_progress");
expect(deriveRecoveryDisplayState({ ...base, recoveryIssueId: "issue-1" })).toBe(
"in_progress",
);
});
it("observes rather than warns when the successful-run handoff has a live continuation", () => {
expect(deriveRecoveryDisplayState(base, { hasLiveContinuation: true })).toBe("observe_only");
});
it("keeps the watchdog observational", () => {
expect(deriveRecoveryDisplayState({ ...base, kind: "active_run_watchdog" })).toBe(
"observe_only",
);
});
it("still escalates a watchdog that escalated", () => {
expect(
deriveRecoveryDisplayState({ ...base, kind: "active_run_watchdog", status: "escalated" }),
).toBe("escalated");
});
it("treats resolved and cancelled as resolved regardless of liveness", () => {
expect(
deriveRecoveryDisplayState(
{ ...base, status: "resolved" },
{ ownerRun: { agentId: CTO_ID, status: "running" } },
),
).toBe("resolved");
expect(deriveRecoveryDisplayState({ ...base, status: "cancelled" })).toBe("resolved");
expect(deriveActiveRecoveryDisplayState({ ...base, status: "resolved" })).toBeNull();
});
});
describe("recoveryLivenessFromIssue", () => {
it("reads the execution lock, queued retry, and handoff projection", () => {
expect(
recoveryLivenessFromIssue({
executionRunId: "run-1",
assigneeAgentId: CTO_ID,
scheduledRetry: { status: "scheduled_retry" },
successfulRunHandoff: { hasLiveContinuation: true },
}),
).toEqual({
sourceHasLiveRun: true,
sourceLiveRunAgentId: CTO_ID,
hasQueuedWake: true,
hasLiveContinuation: true,
});
});
it("does not attribute an assignee when no run is live", () => {
expect(
recoveryLivenessFromIssue({ executionRunId: null, assigneeAgentId: CTO_ID }),
).toMatchObject({ sourceHasLiveRun: false, sourceLiveRunAgentId: null });
});
it("ignores a cancelled scheduled retry", () => {
expect(
recoveryLivenessFromIssue({ scheduledRetry: { status: "cancelled" } }),
).toMatchObject({ hasQueuedWake: false });
});
it("lets explicit signals override the issue-derived ones", () => {
expect(
recoveryLivenessFromIssue(
{ executionRunId: null },
{ sourceHasLiveRun: true, sourceLiveRunAgentId: CODER_ID },
),
).toMatchObject({ sourceHasLiveRun: true, sourceLiveRunAgentId: CODER_ID });
});
it("tolerates a missing issue", () => {
expect(recoveryLivenessFromIssue(null)).toMatchObject({ sourceHasLiveRun: false });
});
});
describe("deriveRecoveryPresentation", () => {
it("returns null without an action", () => {
expect(deriveRecoveryPresentation({ action: null })).toBeNull();
});
it("names the live recovery owner from the run list", () => {
expect(
deriveRecoveryPresentation({
action: base,
liveRuns: [{ agentId: CTO_ID, agentName: "CTO", status: "running" }],
}),
).toEqual({ state: "in_progress", ownerName: "CTO", recessed: true });
});
it("falls back to the agent map for the owner name", () => {
expect(
deriveRecoveryPresentation({
action: base,
scheduledRetry: { status: "queued" },
resolveAgentName: (agentId) => (agentId === CTO_ID ? "CTO" : null),
}),
).toEqual({ state: "in_progress", ownerName: "CTO", recessed: true });
});
it("counts a live-issue id as source liveness", () => {
expect(
deriveRecoveryPresentation({
action: base,
issueId: "issue-1",
liveIssueIds: new Set(["issue-1"]),
issueAssigneeAgentId: CODER_ID,
}),
).toMatchObject({ state: "observe_only", recessed: true });
});
it("keeps an ownerless action prominent", () => {
expect(
deriveRecoveryPresentation({ action: { ...base, ownerAgentId: null } }),
).toEqual({ state: "needed", ownerName: null, recessed: false });
});
it("ignores terminal runs in the live list", () => {
expect(
deriveRecoveryPresentation({
action: base,
liveRuns: [{ agentId: CTO_ID, agentName: "CTO", status: "completed" }],
}),
).toMatchObject({ state: "needed", recessed: false });
});
});
+202 -9
View File
@@ -10,58 +10,251 @@ export type RecoveryDisplayState =
export type ActiveRecoveryDisplayState = Exclude<RecoveryDisplayState, "resolved">;
const RECOVERY_DISPLAY_STATES = new Set<string>([
"needed",
"in_progress",
"observe_only",
"escalated",
"resolved",
]);
export const RECOVERY_CHIP_DEFAULT_TONE: Record<
ActiveRecoveryDisplayState,
{ className: string; icon: typeof TriangleAlert; label: string }
{ className: string; icon: typeof TriangleAlert; label: string; recessed: boolean }
> = {
needed: {
className:
"border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
icon: TriangleAlert,
label: "Recovery needed",
recessed: false,
},
// Recessed on purpose: a recovery owner is already live or queued, so this is
// progress reporting, not a call to action. Prominent amber/red is reserved
// for `needed` (no owner/path) and `escalated` (a decision is truly required).
in_progress: {
className:
"border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
className: "border-border bg-muted text-muted-foreground",
icon: RefreshCw,
label: "Recovery in progress",
recessed: true,
},
observe_only: {
className: "border-border bg-muted text-muted-foreground",
icon: Eye,
label: "Observing active run",
recessed: true,
},
escalated: {
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
icon: OctagonAlert,
label: "Recovery escalated",
recessed: false,
},
};
/** States that render as a quiet, foldable line instead of a prominent card. */
export function isRecessedRecoveryState(state: RecoveryDisplayState): boolean {
return state === "in_progress" || state === "observe_only" || state === "resolved";
}
/**
* Liveness a surface knows about the *source* task and the recovery owner.
*
* Recovery classification is only honest when it accounts for who is actually
* running: a recovery action whose owner is mid-run is "in progress", and a
* newer durable source-owner path invalidates a stale recovery action. Surfaces
* pass whatever they have; every field is optional and absence means "unknown"
* (which degrades to the pre-liveness classification).
*/
export interface RecoveryLivenessSignals {
/**
* A run the surface is already rendering (e.g. the run card in the active-agents
* panel). Counts as recovery-owner liveness when its agent owns the action.
*/
ownerRun?: { agentId?: string | null; status?: string | null } | null;
/** The source task holds a live/queued execution run. */
sourceHasLiveRun?: boolean;
/** Agent behind the source task's live run, when known. */
sourceLiveRunAgentId?: string | null;
/** A queued wake (scheduled retry) is pending on the source task. */
hasQueuedWake?: boolean;
/** The successful-run handoff projection reports a live continuation. */
hasLiveContinuation?: boolean;
}
const LIVE_RUN_STATUSES = new Set(["queued", "running"]);
const QUEUED_WAKE_STATUSES = new Set(["scheduled_retry", "queued", "running"]);
/** Shape of the fields `recoveryLivenessFromIssue` reads; satisfied by `Issue`/`CompactIssue`. */
export interface RecoveryLivenessIssueLike {
id?: string;
status?: string | null;
executionRunId?: string | null;
assigneeAgentId?: string | null;
scheduledRetry?: { status?: string | null } | null;
successfulRunHandoff?: { hasLiveContinuation?: boolean } | null;
}
/**
* Derives liveness from an issue payload alone — the execution lock, the queued
* retry, and the successful-run handoff projection. Surfaces with richer run
* data (a live run list, a rendered run card) should merge it in via `extra`.
*/
export function recoveryLivenessFromIssue(
issue: RecoveryLivenessIssueLike | null | undefined,
extra?: RecoveryLivenessSignals,
): RecoveryLivenessSignals {
const sourceHasLiveRun = Boolean(issue?.executionRunId);
const retryStatus = issue?.scheduledRetry?.status ?? null;
return {
sourceHasLiveRun,
sourceLiveRunAgentId: sourceHasLiveRun ? issue?.assigneeAgentId ?? null : null,
hasQueuedWake: retryStatus ? QUEUED_WAKE_STATUSES.has(retryStatus) : false,
hasLiveContinuation: issue?.successfulRunHandoff?.hasLiveContinuation ?? false,
...extra,
};
}
/**
* The server-side recovery projection (`displayState`), read defensively.
*
* The owner-sticky recovery contract publishes the authoritative state on the
* action itself. Until every deployment emits it the field is absent, so the
* client derivation below stands in — but whenever the server does project a
* state, it wins over anything inferred here.
*/
function readProjectedState(action: object): RecoveryDisplayState | null {
const projected = (action as { displayState?: unknown }).displayState;
return typeof projected === "string" && RECOVERY_DISPLAY_STATES.has(projected)
? (projected as RecoveryDisplayState)
: null;
}
type RecoveryActionForDisplay = Pick<IssueRecoveryAction, "status" | "kind" | "outcome"> &
Partial<Pick<IssueRecoveryAction, "ownerAgentId" | "recoveryIssueId">>;
function ownerRunIsLive(
action: RecoveryActionForDisplay,
signals: RecoveryLivenessSignals | undefined,
): boolean {
const run = signals?.ownerRun;
if (!run) return false;
if (!run.status || !LIVE_RUN_STATUSES.has(run.status)) return false;
// An unattributed run can't be proven to be the recovery owner's.
if (!action.ownerAgentId || !run.agentId) return false;
return run.agentId === action.ownerAgentId;
}
export function deriveRecoveryDisplayState(
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
action: RecoveryActionForDisplay,
signals?: RecoveryLivenessSignals,
): RecoveryDisplayState {
const projected = readProjectedState(action);
if (projected) return projected;
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";
// The watchdog never asks for a decision — it exists to observe a live run.
if (action.kind === "active_run_watchdog" && action.status !== "escalated") {
return "observe_only";
}
// A proven live/queued run by the recovery owner outranks escalation: the
// escalation already found an owner and that owner is working it.
if (ownerRunIsLive(action, signals)) return "in_progress";
if (action.status === "escalated") return "escalated";
if (signals?.sourceHasLiveRun) {
const ownerIsRunning =
Boolean(action.ownerAgentId) && signals.sourceLiveRunAgentId === action.ownerAgentId;
// Someone other than the recovery owner is live on the source task — a newer
// durable source-owner path that folds this action down to observation.
return ownerIsRunning ? "in_progress" : "observe_only";
}
if (signals?.hasQueuedWake) return "in_progress";
if (action.outcome === "delegated") return "in_progress";
if (action.recoveryIssueId) return "in_progress";
if (signals?.hasLiveContinuation) return "observe_only";
return "needed";
}
export function deriveActiveRecoveryDisplayState(
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
action: RecoveryActionForDisplay,
signals?: RecoveryLivenessSignals,
): ActiveRecoveryDisplayState | null {
const state = deriveRecoveryDisplayState(action);
const state = deriveRecoveryDisplayState(action, signals);
return state === "resolved" ? null : state;
}
/** Minimal run shape the presentation derivation needs; `LiveRunForIssue` satisfies it. */
export interface RecoveryLivenessRun {
agentId?: string | null;
agentName?: string | null;
status?: string | null;
}
export interface RecoveryPresentation {
state: RecoveryDisplayState;
/** Name of the live/queued recovery owner, when resolvable. */
ownerName: string | null;
/** True when the surface should render the recessed line instead of the card. */
recessed: boolean;
}
/**
* One derivation shared by every task thread: classify the action against the
* task's live runs, queued wake, and handoff projection, and resolve the owner's
* display name for the "Recovery in progress · CTO" line.
*/
export function deriveRecoveryPresentation(input: {
action: RecoveryActionForDisplay | null | undefined;
liveRuns?: readonly RecoveryLivenessRun[];
issueId?: string | null;
liveIssueIds?: ReadonlySet<string> | null;
issueAssigneeAgentId?: string | null;
scheduledRetry?: { status?: string | null } | null;
successfulRunHandoff?: { hasLiveContinuation?: boolean } | null;
resolveAgentName?: (agentId: string) => string | null | undefined;
}): RecoveryPresentation | null {
const { action } = input;
if (!action) return null;
const liveRuns = (input.liveRuns ?? []).filter(
(run) => run.status === "queued" || run.status === "running",
);
const ownerRun = action.ownerAgentId
? liveRuns.find((run) => run.agentId === action.ownerAgentId) ?? null
: null;
const sourceRun = liveRuns[0] ?? null;
const state = deriveRecoveryDisplayState(
action,
recoveryLivenessFromIssue(
{
scheduledRetry: input.scheduledRetry ?? null,
successfulRunHandoff: input.successfulRunHandoff ?? null,
},
{
ownerRun: ownerRun ? { agentId: ownerRun.agentId, status: ownerRun.status } : null,
sourceHasLiveRun:
liveRuns.length > 0 || Boolean(input.issueId && input.liveIssueIds?.has(input.issueId)),
sourceLiveRunAgentId: sourceRun?.agentId ?? input.issueAssigneeAgentId ?? null,
},
),
);
const ownerName =
ownerRun?.agentName ??
(action.ownerAgentId ? input.resolveAgentName?.(action.ownerAgentId) ?? null : null);
return { state, ownerName, recessed: isRecessedRecoveryState(state) };
}
export function recoveryChipLabel(
state: ActiveRecoveryDisplayState,
kind: IssueRecoveryActionKind,
options?: { ownerName?: string | null },
): string {
if (kind === "workspace_validation" && state === "needed") {
return "Workspace recovery needed";
}
return RECOVERY_CHIP_DEFAULT_TONE[state].label;
const label = RECOVERY_CHIP_DEFAULT_TONE[state].label;
const ownerName = options?.ownerName?.trim();
// "Recovery in progress · CTO" — naming the live owner is what makes the
// recessed line legible without expanding it.
return state === "in_progress" && ownerName ? `${label} · ${ownerName}` : label;
}
@@ -3,6 +3,7 @@ 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 { RecoveryProgressLine } from "@/components/RecoveryProgressLine";
import { IssueRow } from "@/components/IssueRow";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData";
@@ -261,7 +262,8 @@ const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof Triang
in_progress: {
icon: RefreshCw,
label: "Recovery in progress",
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
// Recessed: a live/queued recovery owner is progress, not a call to action.
className: "border-border bg-muted text-muted-foreground",
},
observe_only: {
icon: Eye,
@@ -467,3 +469,73 @@ export const ActiveRunPanelRecoveryChips: Story = {
</StoryFrame>
),
};
function LivenessAwarePanel() {
return (
<div className="space-y-6">
<section className="space-y-2">
<h2 className="text-sm font-semibold">Recovery owner is live or queued</h2>
<p className="text-sm text-muted-foreground">
The recessed line replaces the prominent card. It names the owner, folds all evidence
behind one disclosure, and never claims recovery is “needed” while someone is on it.
</p>
<div className="rounded-lg border border-border/60 bg-background p-2">
<RecoveryProgressLine
state="in_progress"
ownerName={claudeAgent.name}
action={buildAction({ kind: "stranded_assigned_issue" })}
agentMap={storybookAgentMap}
onResolve={() => undefined}
/>
</div>
</section>
<section className="space-y-2">
<h2 className="text-sm font-semibold">Expanded — evidence stays inspectable</h2>
<div className="rounded-lg border border-border/60 bg-background p-2">
<RecoveryProgressLine
state="in_progress"
ownerName={claudeAgent.name}
defaultOpen
action={buildAction({ kind: "stranded_assigned_issue" })}
agentMap={storybookAgentMap}
onResolve={() => undefined}
/>
</div>
</section>
<section className="space-y-2">
<h2 className="text-sm font-semibold">A newer owner took the task over</h2>
<div className="rounded-lg border border-border/60 bg-background p-2">
<RecoveryProgressLine
state="observe_only"
action={buildAction()}
agentMap={storybookAgentMap}
onResolve={() => undefined}
/>
</div>
</section>
<section className="space-y-2">
<h2 className="text-sm font-semibold">No owner, no path — prominence is earned</h2>
<IssueRecoveryActionCard
action={buildAction({ ownerType: "system", ownerAgentId: null })}
agentMap={storybookAgentMap}
forcedState="needed"
onResolve={() => undefined}
/>
</section>
</div>
);
}
export const LivenessAwareRecovery: Story = {
render: () => (
<StoryFrame
title="Liveness-aware recovery"
description="A live or queued recovery owner renders as a recessed, expandable line — prominent amber/red is reserved for an ownerless action or a true escalation."
>
<LivenessAwarePanel />
</StoryFrame>
),
};