feat(ui): improve failed run cards on inbox — prominent task name + retry button

- 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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 13:55:47 -06:00
parent 25a528a636
commit 9e1e1bcd2e

View File

@@ -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<string, string> = {
timer: "Scheduled",
@@ -109,6 +118,131 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null;
}
function FailedRunCard({
run,
issueById,
agentName: linkedAgentName,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
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<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" />
<div className="relative space-y-3">
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
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>
)}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-400" />
</span>
{linkedAgentName ? (
<Identity name={linkedAgentName} size="sm" />
) : (
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
)}
<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 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 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 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>
</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>
);
}
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<string, string>();
@@ -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() {
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="assigned_to_me">Assigned to me</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
@@ -411,6 +572,37 @@ export function Inbox() {
/>
)}
{showAssignedSection && (
<>
{showSeparatorBefore("assigned_to_me") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Assigned To Me
</h3>
<div className="divide-y divide-border border border-border">
{assignedToMeIssues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
>
<UserCheck className="h-4 w-4 shrink-0 text-blue-400" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)}
</span>
</Link>
))}
</div>
</div>
</>
)}
{showApprovalsSection && (
<>
{showSeparatorBefore("approvals") && <Separator />}
@@ -501,74 +693,14 @@ export function Inbox() {
Failed Runs
</h3>
<div className="grid gap-3">
{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 (
<div
key={run.id}
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" />
<div className="relative space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-400" />
</span>
{linkedAgentName ? (
<Identity name={linkedAgentName} size="sm" />
) : (
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
)}
<StatusBadge status={run.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{sourceLabel} run failed {timeAgo(run.createdAt)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 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>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
{displayError}
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="truncate text-muted-foreground transition-colors hover:text-foreground no-underline"
>
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
</Link>
) : (
<span className="text-muted-foreground">
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
</span>
)}
</div>
</div>
</div>
);
})}
{failedRuns.map((run) => (
<FailedRunCard
key={run.id}
run={run}
issueById={issueById}
agentName={agentName(run.agentId)}
/>
))}
</div>
</div>
</>