Interleave failed runs with issues and approvals in inbox

Failed runs are no longer shown in a separate section. They are now
mixed into the main work items feed sorted by timestamp, matching
how approvals are already interleaved with issues.

Replaced the large FailedRunCard with a compact FailedRunInboxRow
that matches the ApprovalInboxRow visual style.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 07:24:52 -05:00
parent 72a0e256a8
commit 25af0a1532
3 changed files with 140 additions and 153 deletions

View File

@@ -289,7 +289,11 @@ describe("inbox helpers", () => {
getInboxWorkItems({
issues: [olderIssue, newerIssue],
approvals: [approval],
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`),
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"approval:approval-between",

View File

@@ -23,6 +23,11 @@ export type InboxWorkItem =
kind: "approval";
timestamp: number;
approval: Approval;
}
| {
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
};
export interface InboxBadgeData {
@@ -146,9 +151,11 @@ export function approvalActivityTimestamp(approval: Approval): number {
export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
@@ -161,6 +168,11 @@ export function getInboxWorkItems({
timestamp: approvalActivityTimestamp(approval),
approval,
})),
...failedRuns.map((run) => ({
kind: "failed_run" as const,
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;

View File

@@ -33,12 +33,10 @@ import {
import {
Inbox as InboxIcon,
AlertTriangle,
ArrowUpRight,
XCircle,
X,
RotateCcw,
} from "lucide-react";
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
@@ -64,16 +62,8 @@ type InboxCategoryFilter =
type SectionKey =
| "work_items"
| "join_requests"
| "failed_runs"
| "alerts";
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
assignment: "Assignment",
on_demand: "Manual",
automation: "Automation",
};
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null;
}
function FailedRunCard({
function FailedRunInboxRow({
run,
issueById,
agentName: linkedAgentName,
issueLinkState,
onDismiss,
onRetry,
isRetrying,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
agentName: string | null;
issueLinkState: unknown;
onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
const displayError = runFailureMessage(run);
const retryRun = useMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {};
const context = run.contextSnapshot as Record<string, unknown> | null;
if (context) {
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
}
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload,
});
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return result;
},
onSuccess: (newRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
navigate(`/agents/${run.agentId}/runs/${newRun.id}`);
},
});
return (
<div className="group relative overflow-hidden rounded-xl border border-red-500/30 bg-gradient-to-br from-red-500/10 via-card to-card p-4">
<div className="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
<button
type="button"
onClick={onDismiss}
className="absolute right-2 top-2 z-10 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
<div className="relative space-y-3">
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
>
<span className="font-mono text-muted-foreground mr-1.5">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.title}
</Link>
) : (
<span className="block text-sm text-muted-foreground">
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"}
<div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
</span>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
</span>
{linkedAgentName ? (
<Identity name={linkedAgentName} size="sm" />
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{issue ? (
<>
<span className="font-mono text-muted-foreground mr-1.5">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.title}
</>
) : (
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
<>Failed run{linkedAgentName ? `${linkedAgentName}` : ""}</>
)}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<StatusBadge status={run.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{sourceLabel} run failed {timeAgo(run.createdAt)}
</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
onClick={() => retryRun.mutate()}
disabled={retryRun.isPending}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryRun.isPending ? "Retrying…" : "Retry"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
asChild
>
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
Open run
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</div>
{linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
<span className="truncate max-w-[300px]">{displayError}</span>
<span>{timeAgo(run.createdAt)}</span>
</span>
</span>
</Link>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
onClick={onRetry}
disabled={isRetrying}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
{displayError}
</div>
<div className="text-xs">
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
</div>
{retryRun.isError && (
<div className="text-xs text-destructive">
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
</div>
)}
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
onClick={onRetry}
disabled={isRetrying}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
@@ -473,13 +426,19 @@ export function Inbox() {
const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const failedRunsForTab = useMemo(() => {
if (tab === "all" && !showFailedRunsCategory) return [];
return failedRuns;
}, [failedRuns, tab, showFailedRunsCategory]);
const workItemsToRender = useMemo(
() =>
getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
failedRuns: failedRunsForTab,
}),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
);
const agentName = (id: string | null) => {
@@ -538,6 +497,33 @@ export function Inbox() {
},
});
const retryRunMutation = useMutation({
mutationFn: async (run: HeartbeatRun) => {
const payload: Record<string, unknown> = {};
const context = run.contextSnapshot as Record<string, unknown> | null;
if (context) {
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
}
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload,
});
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return { newRun: result, originalRun: run };
},
onSuccess: ({ newRun, originalRun }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) });
navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`);
},
});
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => {
@@ -607,13 +593,6 @@ export function Inbox() {
const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showFailedRunsSection = shouldShowInboxSection({
tab,
hasItems: hasRunFailures,
showOnRecent: hasRunFailures,
showOnUnread: hasRunFailures,
showOnAll: showFailedRunsCategory && hasRunFailures,
});
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
@@ -623,7 +602,6 @@ export function Inbox() {
});
const visibleSections = [
showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : null,
@@ -751,6 +729,21 @@ export function Inbox() {
);
}
if (item.kind === "failed_run") {
return (
<FailedRunInboxRow
key={`run:${item.run.id}`}
run={item.run}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryRunMutation.isPending}
/>
);
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
@@ -857,28 +850,6 @@ export function Inbox() {
</>
)}
{showFailedRunsSection && (
<>
{showSeparatorBefore("failed_runs") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Failed Runs
</h3>
<div className="grid gap-3">
{failedRuns.map((run) => (
<FailedRunCard
key={run.id}
run={run}
issueById={issueById}
agentName={agentName(run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${run.id}`)}
/>
))}
</div>
</div>
</>
)}
{showAlertsSection && (
<>