mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Surface source recovery state on lists, blockers, and run panels
Phase 3B follow-up to the source-scoped recovery action backend (PAP-9132)
and UX (PAP-9133). Wires the recovery primitive into the surfaces named in
the approved plan so users see and resolve recovery from the source issue
without a duplicate sibling row.
- Project activeRecoveryAction onto every list-shaped issue payload:
/companies/:companyId/issues and /agents/me/inbox-lite. The IssueRow
recovery chip now shows wherever those endpoints feed (inbox, search,
child lists), not just on the issue detail page.
- Extend IssueRelationIssueSummary with activeRecoveryAction and decorate
blocker/blocks chips on /issues/:id and the heartbeat-context route. The
IssueBlockedNotice chips inline a "Recovery needed/in progress/observing
active run/escalated" indicator when the blocker has an active recovery
action; bare blockers stay clean.
- Add a recovery indicator to the dashboard ActiveAgentsPanel run cards so
live runs flag when their linked source issue has a recovery action open.
- Storybook: add BlockerNoticeRecoveryIndicators and
ActiveRunPanelRecoveryChips stories, extend the screenshot script to
cover them at desktop + mobile, light + dark.
Verification:
- pnpm --filter @paperclipai/{ui,shared,server} typecheck
- pnpm exec vitest run on IssueBlockedNotice/IssueRecoveryActionCard/IssueRow
(4+9+11 = 24 tests pass; new test asserts the blocker recovery indicator)
- Storybook static rebuild + 16 fresh screenshots in
tmp/pap-9134-recovery-screens/ (4 stories x 2 viewports x 2 themes)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
dd1b5244e5
commit
ed2c6382e3
8 files changed
+437
-16
No files matched your search
@@ -135,6 +135,7 @@ export interface IssueRelationIssueSummary {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
terminalBlockers?: IssueRelationIssueSummary[];
|
||||
activeRecoveryAction?: IssueRecoveryAction | null;
|
||||
}
|
||||
|
||||
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
|
||||
|
||||
@@ -26,6 +26,14 @@ const STORIES = [
|
||||
id: "paperclip-source-issue-recovery--inbox-row-chips",
|
||||
label: "inbox-rows",
|
||||
},
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--blocker-notice-recovery-indicators",
|
||||
label: "blocker-notice",
|
||||
},
|
||||
{
|
||||
id: "paperclip-source-issue-recovery--active-run-panel-recovery-chips",
|
||||
label: "active-run-panel",
|
||||
},
|
||||
];
|
||||
|
||||
const THEMES = [
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
heartbeatService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
issueApprovalService,
|
||||
issueRecoveryActionService,
|
||||
issueService,
|
||||
logActivity,
|
||||
syncInstructionsBundleConfigFromFilePath,
|
||||
@@ -1741,16 +1742,18 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
assigneeAgentId: req.actor.agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
includeRoutineExecutions: true,
|
||||
limit: ISSUE_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
const dependencyReadiness = await issuesSvc.listDependencyReadiness(
|
||||
req.actor.companyId,
|
||||
rows.map((issue) => issue.id),
|
||||
);
|
||||
const issueIds = rows.map((issue) => issue.id);
|
||||
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
|
||||
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
|
||||
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
|
||||
]);
|
||||
|
||||
res.json(
|
||||
rows.map((issue) => ({
|
||||
@@ -1764,6 +1767,7 @@ export function agentRoutes(
|
||||
parentId: issue.parentId,
|
||||
updatedAt: issue.updatedAt,
|
||||
activeRun: issue.activeRun,
|
||||
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
|
||||
dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true,
|
||||
unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0,
|
||||
unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [],
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type CompanySearchQuery,
|
||||
type CompanySearchResponse,
|
||||
type ExecutionWorkspace,
|
||||
type IssueRelationIssueSummary,
|
||||
type SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
@@ -406,6 +407,36 @@ async function listSuccessfulRunHandoffStates(
|
||||
return states;
|
||||
}
|
||||
|
||||
type RecoveryActionsLister = {
|
||||
listActiveForIssues: (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
|
||||
};
|
||||
|
||||
async function annotateRelationSummariesWithRecoveryActions(
|
||||
recoveryActionsSvc: RecoveryActionsLister,
|
||||
companyId: string,
|
||||
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
||||
) {
|
||||
const candidates: IssueRelationIssueSummary[] = [];
|
||||
const visit = (summary: IssueRelationIssueSummary) => {
|
||||
candidates.push(summary);
|
||||
for (const terminal of summary.terminalBlockers ?? []) {
|
||||
visit(terminal);
|
||||
}
|
||||
};
|
||||
for (const blocker of relations.blockedBy) visit(blocker);
|
||||
for (const blocking of relations.blocks) visit(blocking);
|
||||
if (candidates.length === 0) return;
|
||||
const ids = [...new Set(candidates.map((summary) => summary.id))];
|
||||
const map = await recoveryActionsSvc.listActiveForIssues(companyId, ids);
|
||||
for (const summary of candidates) {
|
||||
const action = map.get(summary.id) ?? null;
|
||||
if (action) summary.activeRecoveryAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
|
||||
@@ -1449,14 +1480,15 @@ export function issueRoutes(
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const handoffStates = await listSuccessfulRunHandoffStates(
|
||||
db,
|
||||
companyId,
|
||||
result.map((issue) => issue.id),
|
||||
);
|
||||
const issueIds = result.map((issue) => issue.id);
|
||||
const [handoffStates, recoveryActionByIssue] = await Promise.all([
|
||||
listSuccessfulRunHandoffStates(db, companyId, issueIds),
|
||||
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
|
||||
]);
|
||||
res.json(result.map((issue) => ({
|
||||
...issue,
|
||||
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
|
||||
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
|
||||
})));
|
||||
});
|
||||
|
||||
@@ -1559,6 +1591,11 @@ export function issueRoutes(
|
||||
currentExecutionWorkspacePromise,
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
]);
|
||||
await annotateRelationSummariesWithRecoveryActions(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
relations,
|
||||
);
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
@@ -1668,6 +1705,11 @@ export function issueRoutes(
|
||||
svc.getCurrentScheduledRetry(issue.id),
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
]);
|
||||
await annotateRelationSummariesWithRecoveryActions(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
relations,
|
||||
);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { TranscriptEntry } from "../adapters";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { RunChatSurface } from "./RunChatSurface";
|
||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
|
||||
type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
|
||||
|
||||
function runCardRecoveryStateFor(action: IssueRecoveryAction): RunCardRecoveryState | null {
|
||||
if (action.status === "resolved" || action.status === "cancelled") return null;
|
||||
if (action.status === "escalated") return "escalated";
|
||||
if (action.kind === "active_run_watchdog") return "observe_only";
|
||||
if (action.outcome === "delegated") return "in_progress";
|
||||
return "needed";
|
||||
}
|
||||
|
||||
const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
|
||||
needed: {
|
||||
icon: TriangleAlert,
|
||||
label: "Recovery needed",
|
||||
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
in_progress: {
|
||||
icon: RefreshCw,
|
||||
label: "Recovery in progress",
|
||||
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
observe_only: {
|
||||
icon: Eye,
|
||||
label: "Observing active run",
|
||||
className: "border-border bg-muted text-muted-foreground",
|
||||
},
|
||||
escalated: {
|
||||
icon: OctagonAlert,
|
||||
label: "Recovery escalated",
|
||||
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = runCardRecoveryStateFor(action);
|
||||
if (!state) return null;
|
||||
const tone = RUN_CARD_RECOVERY_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
data-testid="active-agent-run-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
tone.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_DASHBOARD_RUNS = 4;
|
||||
const DASHBOARD_RUN_CARD_LIMIT = 4;
|
||||
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
|
||||
@@ -189,6 +245,11 @@ const AgentRunCard = memo(function AgentRunCard({
|
||||
{issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||
{issue?.title ? ` - ${issue.title}` : ""}
|
||||
</Link>
|
||||
{issue?.activeRecoveryAction ? (
|
||||
<div className="mt-1.5">
|
||||
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -27,11 +29,20 @@ afterEach(() => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
function withProviders(node: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
act(() => root?.render(withProviders(element)));
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -102,4 +113,58 @@ describe("IssueBlockedNotice", () => {
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-123",
|
||||
title: "Build still red",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
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).not.toBeNull();
|
||||
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
|
||||
expect(indicator?.textContent).toContain("Recovery needed");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,68 @@
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueRecoveryAction,
|
||||
IssueRelationIssueSummary,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Eye, Flag, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
|
||||
type BlockerRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
|
||||
|
||||
function blockerRecoveryStateFor(action: IssueRecoveryAction): BlockerRecoveryState | null {
|
||||
if (action.status === "resolved" || action.status === "cancelled") return null;
|
||||
if (action.status === "escalated") return "escalated";
|
||||
if (action.kind === "active_run_watchdog") return "observe_only";
|
||||
if (action.outcome === "delegated") return "in_progress";
|
||||
return "needed";
|
||||
}
|
||||
|
||||
const BLOCKER_RECOVERY_TONE: Record<BlockerRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
|
||||
needed: {
|
||||
icon: TriangleAlert,
|
||||
label: "Recovery needed",
|
||||
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
in_progress: {
|
||||
icon: RefreshCw,
|
||||
label: "Recovery in progress",
|
||||
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
observe_only: {
|
||||
icon: Eye,
|
||||
label: "Observing active run",
|
||||
className: "border-border bg-muted text-muted-foreground",
|
||||
},
|
||||
escalated: {
|
||||
icon: OctagonAlert,
|
||||
label: "Recovery escalated",
|
||||
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
const state = blockerRecoveryStateFor(action);
|
||||
if (!state) return null;
|
||||
const tone = BLOCKER_RECOVERY_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
data-testid="issue-blocked-notice-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
blockers,
|
||||
@@ -69,6 +127,7 @@ export function IssueBlockedNotice({
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
const recoveryAction = blocker.activeRecoveryAction ?? null;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
@@ -80,6 +139,7 @@ export function IssueBlockedNotice({
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactNode } from "react";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type { IssueRecoveryAction, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
|
||||
import { IssueRow } from "@/components/IssueRow";
|
||||
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
|
||||
import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData";
|
||||
|
||||
const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!;
|
||||
@@ -131,6 +133,162 @@ function AllStatesPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildBlocker(
|
||||
overrides: Partial<IssueRelationIssueSummary> = {},
|
||||
): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-9065",
|
||||
title: "Add full company search page",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: claudeAgent.id,
|
||||
assigneeUserId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function BlockerNoticePanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
buildBlocker({ activeRecoveryAction: buildAction() }),
|
||||
buildBlocker({
|
||||
id: "blocker-2",
|
||||
identifier: "PAP-9099",
|
||||
title: "Watchdog: PR review pipeline silent",
|
||||
activeRecoveryAction: buildAction({ kind: "active_run_watchdog" }),
|
||||
}),
|
||||
buildBlocker({
|
||||
id: "blocker-3",
|
||||
identifier: "PAP-9073",
|
||||
title: "Recovery escalated for stranded run",
|
||||
status: "blocked",
|
||||
activeRecoveryAction: buildAction({ status: "escalated" }),
|
||||
}),
|
||||
buildBlocker({
|
||||
id: "blocker-4",
|
||||
identifier: "PAP-9051",
|
||||
title: "Bare blocker without recovery state",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
|
||||
|
||||
const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
|
||||
needed: {
|
||||
icon: TriangleAlert,
|
||||
label: "Recovery needed",
|
||||
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
in_progress: {
|
||||
icon: RefreshCw,
|
||||
label: "Recovery in progress",
|
||||
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
observe_only: {
|
||||
icon: Eye,
|
||||
label: "Observing active run",
|
||||
className: "border-border bg-muted text-muted-foreground",
|
||||
},
|
||||
escalated: {
|
||||
icon: OctagonAlert,
|
||||
label: "Recovery escalated",
|
||||
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
function ActiveRunRecoveryChip({ state }: { state: RunCardRecoveryState }) {
|
||||
const tone = RUN_CARD_RECOVERY_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRunCardMock({
|
||||
identifier,
|
||||
title,
|
||||
recoveryState,
|
||||
}: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
recoveryState: RunCardRecoveryState;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-[260px] w-full max-w-[320px] flex-col overflow-hidden rounded-xl border border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]">
|
||||
<div className="border-b border-border/60 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">CodexCoder</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Live now</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
|
||||
<span className="line-clamp-2 text-cyan-700 dark:text-cyan-300">
|
||||
{identifier} - {title}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<ActiveRunRecoveryChip state={recoveryState} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 px-3 py-2 text-[11px] text-muted-foreground">Live transcript…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRunPanel() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ActiveRunCardMock
|
||||
identifier="PAP-9065"
|
||||
title="Add full company search page"
|
||||
recoveryState="needed"
|
||||
/>
|
||||
<ActiveRunCardMock
|
||||
identifier="PAP-9099"
|
||||
title="Watchdog: PR review pipeline silent"
|
||||
recoveryState="observe_only"
|
||||
/>
|
||||
<ActiveRunCardMock
|
||||
identifier="PAP-9073"
|
||||
title="Recovery escalated for stranded run"
|
||||
recoveryState="escalated"
|
||||
/>
|
||||
<ActiveRunCardMock
|
||||
identifier="PAP-9101"
|
||||
title="Recovery in progress: delegated"
|
||||
recoveryState="in_progress"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxRowPanel() {
|
||||
const baseIssue = createIssue();
|
||||
return (
|
||||
@@ -199,3 +357,25 @@ export const InboxRowChips: Story = {
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const BlockerNoticeRecoveryIndicators: Story = {
|
||||
render: () => (
|
||||
<StoryFrame
|
||||
title="Blocker notice recovery indicators"
|
||||
description="Blocker chips inline a recovery indicator when the blocker has an active recovery action. Plain blockers stay clean."
|
||||
>
|
||||
<BlockerNoticePanel />
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ActiveRunPanelRecoveryChips: Story = {
|
||||
render: () => (
|
||||
<StoryFrame
|
||||
title="Active run panel recovery chips"
|
||||
description="Active run cards on the dashboard expose recovery state on the linked source issue."
|
||||
>
|
||||
<ActiveRunPanel />
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user