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