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({ getInboxWorkItems({
issues: [olderIssue, newerIssue], issues: [olderIssue, newerIssue],
approvals: [approval], 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([ ).toEqual([
"issue:1", "issue:1",
"approval:approval-between", "approval:approval-between",

View file

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

View file

@ -33,12 +33,10 @@ import {
import { import {
Inbox as InboxIcon, Inbox as InboxIcon,
AlertTriangle, AlertTriangle,
ArrowUpRight,
XCircle, XCircle,
X, X,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { import {
@ -64,16 +62,8 @@ type InboxCategoryFilter =
type SectionKey = type SectionKey =
| "work_items" | "work_items"
| "join_requests" | "join_requests"
| "failed_runs"
| "alerts"; | "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 { function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null; return null;
} }
function FailedRunCard({ function FailedRunInboxRow({
run, run,
issueById, issueById,
agentName: linkedAgentName, agentName: linkedAgentName,
issueLinkState, issueLinkState,
onDismiss, onDismiss,
onRetry,
isRetrying,
}: { }: {
run: HeartbeatRun; run: HeartbeatRun;
issueById: Map<string, Issue>; issueById: Map<string, Issue>;
agentName: string | null; agentName: string | null;
issueLinkState: unknown; issueLinkState: unknown;
onDismiss: () => void; onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
}) { }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const issueId = readIssueIdFromRun(run); const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null; const issue = issueId ? issueById.get(issueId) ?? null : null;
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
const displayError = runFailureMessage(run); 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 ( 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="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="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" /> <div className="flex items-start gap-2 sm:items-center">
<button <Link
type="button" to={`/agents/${run.agentId}/runs/${run.id}`}
onClick={onDismiss} className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
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" <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" />
<X className="h-4 w-4" /> <span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
</button> <XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<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"}
</span> </span>
)} <span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> {issue ? (
<div className="min-w-0 flex-1"> <>
<div className="flex flex-wrap items-center gap-2"> <span className="font-mono text-muted-foreground mr-1.5">
<span className="rounded-md bg-red-500/20 p-1.5"> {issue.identifier ?? issue.id.slice(0, 8)}
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" /> </span>
</span> {issue.title}
{linkedAgentName ? ( </>
<Identity name={linkedAgentName} size="sm" />
) : ( ) : (
<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} /> <StatusBadge status={run.status} />
</div> {linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
<p className="mt-2 text-xs text-muted-foreground"> <span className="truncate max-w-[300px]">{displayError}</span>
{sourceLabel} run failed {timeAgo(run.createdAt)} <span>{timeAgo(run.createdAt)}</span>
</p> </span>
</div> </span>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end"> </Link>
<Button <div className="hidden shrink-0 items-center gap-2 sm:flex">
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
className="h-8 shrink-0 px-2.5" size="sm"
onClick={() => retryRun.mutate()} className="h-8 shrink-0 px-2.5"
disabled={retryRun.isPending} onClick={onRetry}
> disabled={isRetrying}
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> >
{retryRun.isPending ? "Retrying…" : "Retry"} <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
</Button> {isRetrying ? "Retrying…" : "Retry"}
<Button </Button>
type="button" <button
variant="outline" type="button"
size="sm" onClick={onDismiss}
className="h-8 shrink-0 px-2.5" className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
asChild aria-label="Dismiss"
> >
<Link to={`/agents/${run.agentId}/runs/${run.id}`}> <X className="h-4 w-4" />
Open run </button>
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</div>
</div> </div>
</div>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm"> <div className="mt-3 flex gap-2 sm:hidden">
{displayError} <Button
</div> type="button"
variant="outline"
<div className="text-xs"> size="sm"
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span> className="h-8 shrink-0 px-2.5"
</div> onClick={onRetry}
disabled={isRetrying}
{retryRun.isError && ( >
<div className="text-xs text-destructive"> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"} {isRetrying ? "Retrying…" : "Retry"}
</div> </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>
</div> </div>
); );
@ -473,13 +426,19 @@ export function Inbox() {
const showFailedRunsCategory = const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const failedRunsForTab = useMemo(() => {
if (tab === "all" && !showFailedRunsCategory) return [];
return failedRuns;
}, [failedRuns, tab, showFailedRunsCategory]);
const workItemsToRender = useMemo( const workItemsToRender = useMemo(
() => () =>
getInboxWorkItems({ getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
failedRuns: failedRunsForTab,
}), }),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab], [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
); );
const agentName = (id: string | null) => { 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 [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => { const invalidateInboxIssueQueries = () => {
@ -607,13 +593,6 @@ export function Inbox() {
const showWorkItemsSection = workItemsToRender.length > 0; const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsSection = const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showFailedRunsSection = shouldShowInboxSection({
tab,
hasItems: hasRunFailures,
showOnRecent: hasRunFailures,
showOnUnread: hasRunFailures,
showOnAll: showFailedRunsCategory && hasRunFailures,
});
const showAlertsSection = shouldShowInboxSection({ const showAlertsSection = shouldShowInboxSection({
tab, tab,
hasItems: hasAlerts, hasItems: hasAlerts,
@ -623,7 +602,6 @@ export function Inbox() {
}); });
const visibleSections = [ const visibleSections = [
showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null, showAlertsSection ? "alerts" : null,
showJoinRequestsSection ? "join_requests" : null, showJoinRequestsSection ? "join_requests" : null,
showWorkItemsSection ? "work_items" : 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 issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = 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 && ( {showAlertsSection && (
<> <>