diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 4821b6cf..13e0ada0 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -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", diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index b9a74f72..98de7055 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -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; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 74a6144a..c75fdeab 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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 = { - 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; 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 = {}; - const context = run.contextSnapshot as Record | 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 ( -
-
- -
- {issue ? ( - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.title} - - ) : ( - - {run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"} +
+
+ +
); @@ -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 = {}; + const context = run.contextSnapshot as Record | 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>(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 ( + 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") && } -
-

- Failed Runs -

-
- {failedRuns.map((run) => ( - dismiss(`run:${run.id}`)} - /> - ))} -
-
- - )} {showAlertsSection && ( <>