From 9e1e1bcd2ebfb869bcff9b53ac090c24749b447a Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 26 Feb 2026 13:55:47 -0600 Subject: [PATCH] =?UTF-8?q?feat(ui):=20improve=20failed=20run=20cards=20on?= =?UTF-8?q?=20inbox=20=E2=80=94=20prominent=20task=20name=20+=20retry=20bu?= =?UTF-8?q?tton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move issue/task name from small bottom-right to prominent top-left position - Add Retry button that wakes the agent with original task context - Extract FailedRunCard into its own component for cleaner code Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 270 ++++++++++++++++++++++++++++++----------- 1 file changed, 201 insertions(+), 69 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 0e691c74..5d99367d 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -33,6 +33,8 @@ import { Clock, ArrowUpRight, XCircle, + UserCheck, + RotateCcw, } from "lucide-react"; import { Identity } from "../components/Identity"; import { PageTabBar } from "../components/PageTabBar"; @@ -45,13 +47,20 @@ const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); type InboxTab = "new" | "all"; type InboxCategoryFilter = | "everything" + | "assigned_to_me" | "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work"; type InboxApprovalFilter = "all" | "actionable" | "resolved"; -type SectionKey = "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work"; +type SectionKey = + | "assigned_to_me" + | "join_requests" + | "approvals" + | "failed_runs" + | "alerts" + | "stale_work"; const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", @@ -109,6 +118,131 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { return null; } +function FailedRunCard({ + run, + issueById, + agentName: linkedAgentName, +}: { + run: HeartbeatRun; + issueById: Map; + agentName: string | null; +}) { + 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"} + + )} + +
+
+
+ + + + {linkedAgentName ? ( + + ) : ( + Agent {run.agentId.slice(0, 8)} + )} + +
+

+ {sourceLabel} run failed {timeAgo(run.createdAt)} +

+
+
+ + +
+
+ +
+ {displayError} +
+ +
+ run {run.id.slice(0, 8)} +
+ + {retryRun.isError && ( +
+ {retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"} +
+ )} +
+
+ ); +} + export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -172,6 +306,18 @@ export function Inbox() { queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { + data: assignedToMeIssuesRaw = [], + isLoading: isAssignedToMeLoading, + } = useQuery({ + queryKey: queryKeys.issues.listAssignedToMe(selectedCompanyId!), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + assigneeUserId: "me", + status: "backlog,todo,in_progress,in_review,blocked", + }), + enabled: !!selectedCompanyId, + }); const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!), @@ -180,6 +326,13 @@ export function Inbox() { }); const staleIssues = issues ? getStaleIssues(issues) : []; + const assignedToMeIssues = useMemo( + () => + [...assignedToMeIssuesRaw].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ), + [assignedToMeIssuesRaw], + ); const agentById = useMemo(() => { const map = new Map(); @@ -289,8 +442,10 @@ export function Inbox() { const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasStale = staleIssues.length > 0; const hasJoinRequests = joinRequests.length > 0; + const hasAssignedToMe = assignedToMeIssues.length > 0; const newItemCount = + assignedToMeIssues.length + joinRequests.length + actionableApprovals.length + failedRuns.length + @@ -300,6 +455,8 @@ export function Inbox() { const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; + const showAssignedCategory = + allCategoryFilter === "everything" || allCategoryFilter === "assigned_to_me"; const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals"; const showFailedRunsCategory = allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; @@ -307,6 +464,7 @@ export function Inbox() { const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work"; const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; + const showAssignedSection = tab === "new" ? hasAssignedToMe : showAssignedCategory && hasAssignedToMe; const showJoinRequestsSection = tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests; const showApprovalsSection = @@ -319,6 +477,7 @@ export function Inbox() { const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale; const visibleSections = [ + showAssignedSection ? "assigned_to_me" : null, showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, showFailedRunsSection ? "failed_runs" : null, @@ -331,6 +490,7 @@ export function Inbox() { !isApprovalsLoading && !isDashboardLoading && !isIssuesLoading && + !isAssignedToMeLoading && !isRunsLoading; const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; @@ -370,6 +530,7 @@ export function Inbox() { All categories + Assigned to me Join requests Approvals Failed runs @@ -411,6 +572,37 @@ export function Inbox() { /> )} + {showAssignedSection && ( + <> + {showSeparatorBefore("assigned_to_me") && } +
+

+ Assigned To Me +

+
+ {assignedToMeIssues.map((issue) => ( + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} + + updated {timeAgo(issue.updatedAt)} + + + ))} +
+
+ + )} + {showApprovalsSection && ( <> {showSeparatorBefore("approvals") && } @@ -501,74 +693,14 @@ export function Inbox() { Failed Runs
- {failedRuns.map((run) => { - 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 linkedAgentName = agentName(run.agentId); - - return ( -
-
-
-
-
-
- - - - {linkedAgentName ? ( - - ) : ( - Agent {run.agentId.slice(0, 8)} - )} - -
-

- {sourceLabel} run failed {timeAgo(run.createdAt)} -

-
- -
- -
- {displayError} -
- -
- run {run.id.slice(0, 8)} - {issue ? ( - - {issue.identifier ?? issue.id.slice(0, 8)} ยท {issue.title} - - ) : ( - - {run.errorCode ? `code: ${run.errorCode}` : "No linked issue"} - - )} -
-
-
- ); - })} + {failedRuns.map((run) => ( + + ))}