diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx
index e7b9d42be9e..a4d97098692 100644
--- a/ui/src/components/ActiveAgentsPanel.tsx
+++ b/ui/src/components/ActiveAgentsPanel.tsx
@@ -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 (
- {tone.label}
+ {label}
);
}
@@ -230,7 +253,7 @@ const AgentRunCard = memo(function AgentRunCard({
{issue?.activeRecoveryAction ? (
-
+
) : null}
diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx
index 1880fe2e0c8..0a00f02728e 100644
--- a/ui/src/components/BlockedInboxView.tsx
+++ b/ui/src/components/BlockedInboxView.tsx
@@ -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 = (
@@ -316,6 +334,7 @@ function BlockedInboxRow({
@@ -377,6 +396,7 @@ function BlockedInboxRow({
}
diff --git a/ui/src/components/BlockedReasonChip.test.tsx b/ui/src/components/BlockedReasonChip.test.tsx
index f056914c018..494ff127392 100644
--- a/ui/src/components/BlockedReasonChip.test.tsx
+++ b/ui/src/components/BlockedReasonChip.test.tsx
@@ -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(
+ ,
+ );
+ });
+
+ 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();
+ });
+
+ const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
+ expect(chip?.getAttribute("data-variant")).toBe("recovery_required");
+ expect(chip?.textContent).toContain("Recovery required");
+ });
});
diff --git a/ui/src/components/BlockedReasonChip.tsx b/ui/src/components/BlockedReasonChip.tsx
index 506f5da7de0..a6a962a5db3 100644
--- a/ui/src/components/BlockedReasonChip.tsx
+++ b/ui/src/components/BlockedReasonChip.tsx
@@ -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 = {
"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 = {
const VARIANT_ICONS: Record = {
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> = {
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];
diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx
index 70fc8027090..770de73eb59 100644
--- a/ui/src/components/IssueBlockedNotice.test.tsx
+++ b/ui/src/components/IssueBlockedNotice.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
{
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 (
{blocker.title}
- {recoveryAction ? : null}
+ {recoveryAction ? (
+
+ ) : null}
);
};
diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx
index 3494a938618..2169f928319 100644
--- a/ui/src/components/IssueChatThread.test.tsx
+++ b/ui/src/components/IssueChatThread.test.tsx
@@ -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(
+
+ {}}
+ onAdd={async () => {}}
+ enableLiveTranscriptPolling={false}
+ />
+ ,
+ );
+ });
+ 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");
+ });
});
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index abb3d149576..94315818851 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -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 ? (
-
+ {recoveryAction && recoveryDisplay ? (
+ recoveryDisplay.state === "in_progress" ||
+ recoveryDisplay.state === "observe_only" ? (
+
+ ) : (
+
+ )
) : null}
{legacyRecoverySourceIssue ? (
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({
Recovery is observing without interrupting the live run.
+ ) : cardState === "in_progress" ? (
+
+ {ownerName ? `${ownerName} is on it` : "An owner is on it"} — resolve only to
+ override the in-flight recovery.
+
) : (
The card stays open until an explicit decision is recorded.
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx
index a3ef0343aa8..7a1b83e6a0e 100644
--- a/ui/src/components/IssueRow.test.tsx
+++ b/ui/src/components/IssueRow.test.tsx
@@ -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;
+
+ function renderRow(issue: Issue) {
+ const root = createRoot(container);
+ act(() => {
+ root.render();
+ });
+ 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);
diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx
index bcede8f1ddc..d3c651553b3 100644
--- a/ui/src/components/IssueRow.tsx
+++ b/ui/src/components/IssueRow.tsx
@@ -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({
) : 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) ? (
({
+ Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => (
+ {children}
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+function act(callback: () => T): T {
+ let result: T | undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ return result as T;
+}
+
+let root: ReturnType | 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 {
+ 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([[ctoAgent.id, ctoAgent]]);
+
+describe("RecoveryProgressLine", () => {
+ it("renders a recessed line naming the live recovery owner", () => {
+ const host = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(host.textContent).toContain("Recovery in progress · Ops");
+ });
+
+ it("labels the observe-only state without an owner suffix", () => {
+ const host = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ const toggle = host.querySelector(
+ '[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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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");
+ });
+});
diff --git a/ui/src/components/RecoveryProgressLine.tsx b/ui/src/components/RecoveryProgressLine.tsx
new file mode 100644
index 00000000000..20d5d34a400
--- /dev/null
+++ b/ui/src/components/RecoveryProgressLine.tsx
@@ -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 {
+ /** The recessed state this line represents (`in_progress` or `observe_only`). */
+ state: Extract;
+ /** 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 (
+
+
+ {open ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export default RecoveryProgressLine;
diff --git a/ui/src/components/RunWorkspaceRecoverySurface.tsx b/ui/src/components/RunWorkspaceRecoverySurface.tsx
index bfae52e2761..01846cf0d57 100644
--- a/ui/src/components/RunWorkspaceRecoverySurface.tsx
+++ b/ui/src/components/RunWorkspaceRecoverySurface.tsx
@@ -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 (
@@ -271,6 +283,7 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
+ ) : null}
{composerAccessory}
= {
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 = {
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";
}
diff --git a/ui/src/lib/recovery-display.test.ts b/ui/src/lib/recovery-display.test.ts
index 63235714bce..c1c36d37d2b 100644
--- a/ui/src/lib/recovery-display.test.ts
+++ b/ui/src/lib/recovery-display.test.ts
@@ -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 });
+ });
});
diff --git a/ui/src/lib/recovery-display.ts b/ui/src/lib/recovery-display.ts
index b0f3a92a581..67e3c1403c7 100644
--- a/ui/src/lib/recovery-display.ts
+++ b/ui/src/lib/recovery-display.ts
@@ -10,58 +10,251 @@ export type RecoveryDisplayState =
export type ActiveRecoveryDisplayState = Exclude;
+const RECOVERY_DISPLAY_STATES = new Set([
+ "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 &
+ Partial>;
+
+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,
+ 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,
+ 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 | 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;
}
diff --git a/ui/storybook/stories/source-issue-recovery.stories.tsx b/ui/storybook/stories/source-issue-recovery.stories.tsx
index de29f1f11e9..55b217ee6d6 100644
--- a/ui/storybook/stories/source-issue-recovery.stories.tsx
+++ b/ui/storybook/stories/source-issue-recovery.stories.tsx
@@ -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
),
};
+
+function LivenessAwarePanel() {
+ return (
+
+
+
Recovery owner is live or queued
+
+ 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.
+