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:
@@ -33,6 +33,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
UserCheck,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
@@ -45,13 +47,20 @@ const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
|||||||
type InboxTab = "new" | "all";
|
type InboxTab = "new" | "all";
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
|
| "assigned_to_me"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "stale_work";
|
| "stale_work";
|
||||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
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> = {
|
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||||
timer: "Scheduled",
|
timer: "Scheduled",
|
||||||
@@ -109,6 +118,131 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
|||||||
return 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() {
|
export function Inbox() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
@@ -172,6 +306,18 @@ export function Inbox() {
|
|||||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
enabled: !!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({
|
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
||||||
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
||||||
@@ -180,6 +326,13 @@ export function Inbox() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
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 agentById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -289,8 +442,10 @@ export function Inbox() {
|
|||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasStale = staleIssues.length > 0;
|
const hasStale = staleIssues.length > 0;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
|
const hasAssignedToMe = assignedToMeIssues.length > 0;
|
||||||
|
|
||||||
const newItemCount =
|
const newItemCount =
|
||||||
|
assignedToMeIssues.length +
|
||||||
joinRequests.length +
|
joinRequests.length +
|
||||||
actionableApprovals.length +
|
actionableApprovals.length +
|
||||||
failedRuns.length +
|
failedRuns.length +
|
||||||
@@ -300,6 +455,8 @@ export function Inbox() {
|
|||||||
|
|
||||||
const showJoinRequestsCategory =
|
const showJoinRequestsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
|
const showAssignedCategory =
|
||||||
|
allCategoryFilter === "everything" || allCategoryFilter === "assigned_to_me";
|
||||||
const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
||||||
const showFailedRunsCategory =
|
const showFailedRunsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||||
@@ -307,6 +464,7 @@ export function Inbox() {
|
|||||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
||||||
|
|
||||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||||
|
const showAssignedSection = tab === "new" ? hasAssignedToMe : showAssignedCategory && hasAssignedToMe;
|
||||||
const showJoinRequestsSection =
|
const showJoinRequestsSection =
|
||||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
||||||
const showApprovalsSection =
|
const showApprovalsSection =
|
||||||
@@ -319,6 +477,7 @@ export function Inbox() {
|
|||||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
|
showAssignedSection ? "assigned_to_me" : null,
|
||||||
showApprovalsSection ? "approvals" : null,
|
showApprovalsSection ? "approvals" : null,
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
@@ -331,6 +490,7 @@ export function Inbox() {
|
|||||||
!isApprovalsLoading &&
|
!isApprovalsLoading &&
|
||||||
!isDashboardLoading &&
|
!isDashboardLoading &&
|
||||||
!isIssuesLoading &&
|
!isIssuesLoading &&
|
||||||
|
!isAssignedToMeLoading &&
|
||||||
!isRunsLoading;
|
!isRunsLoading;
|
||||||
|
|
||||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||||
@@ -370,6 +530,7 @@ export function Inbox() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="everything">All categories</SelectItem>
|
<SelectItem value="everything">All categories</SelectItem>
|
||||||
|
<SelectItem value="assigned_to_me">Assigned to me</SelectItem>
|
||||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||||
<SelectItem value="approvals">Approvals</SelectItem>
|
<SelectItem value="approvals">Approvals</SelectItem>
|
||||||
<SelectItem value="failed_runs">Failed runs</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 && (
|
{showApprovalsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("approvals") && <Separator />}
|
{showSeparatorBefore("approvals") && <Separator />}
|
||||||
@@ -501,74 +693,14 @@ export function Inbox() {
|
|||||||
Failed Runs
|
Failed Runs
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{failedRuns.map((run) => {
|
{failedRuns.map((run) => (
|
||||||
const issueId = readIssueIdFromRun(run);
|
<FailedRunCard
|
||||||
const issue = issueId ? issueById.get(issueId) ?? null : null;
|
key={run.id}
|
||||||
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
|
run={run}
|
||||||
const displayError = runFailureMessage(run);
|
issueById={issueById}
|
||||||
const linkedAgentName = agentName(run.agentId);
|
agentName={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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user