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 }) {
{ 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( + {}} + 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( + {}} + 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( {}} issueId="issue-1" />); + expect(container.querySelector('[data-testid="task-chat-recovery-notice"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 86ce55de929..72cc1621d42 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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[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 ? ( +
+ {recoveryDisplay.state === "in_progress" || + recoveryDisplay.state === "observe_only" ? ( + + ) : ( + + )} +
+ ) : 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. +

+
+ undefined} + /> +
+
+ +
+

Expanded — evidence stays inspectable

+
+ undefined} + /> +
+
+ +
+

A newer owner took the task over

+
+ undefined} + /> +
+
+ +
+

No owner, no path — prominence is earned

+ undefined} + /> +
+
+ ); +} + +export const LivenessAwareRecovery: Story = { + render: () => ( + + + + ), +};