feat(ui): add dismiss buttons to inbox errors and failures

Failed runs, alerts, and stale work items can now be dismissed via an
X button that appears on hover. Dismissed items are stored in
localStorage and filtered from the inbox view and item count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-06 07:27:35 -06:00
parent de7d6294ea
commit eb033a221f

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router"; import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
@@ -34,6 +34,7 @@ import {
Clock, Clock,
ArrowUpRight, ArrowUpRight,
XCircle, XCircle,
X,
UserCheck, UserCheck,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
@@ -63,6 +64,36 @@ type SectionKey =
| "alerts" | "alerts"
| "stale_work"; | "stale_work";
const DISMISSED_KEY = "paperclip:inbox:dismissed";
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function saveDismissed(ids: Set<string>) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
}
function useDismissedItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
const dismiss = useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissed(next);
return next;
});
}, []);
return { dismissed, dismiss };
}
const RUN_SOURCE_LABELS: Record<string, string> = { const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled", timer: "Scheduled",
assignment: "Assignment", assignment: "Assignment",
@@ -123,10 +154,12 @@ function FailedRunCard({
run, run,
issueById, issueById,
agentName: linkedAgentName, agentName: linkedAgentName,
onDismiss,
}: { }: {
run: HeartbeatRun; run: HeartbeatRun;
issueById: Map<string, Issue>; issueById: Map<string, Issue>;
agentName: string | null; agentName: string | null;
onDismiss: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -165,6 +198,14 @@ function FailedRunCard({
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 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="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
<button
type="button"
onClick={onDismiss}
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"
>
<X className="h-4 w-4" />
</button>
<div className="relative space-y-3"> <div className="relative space-y-3">
{issue ? ( {issue ? (
<Link <Link
@@ -253,6 +294,7 @@ export function Inbox() {
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedItems();
const pathSegment = location.pathname.split("/").pop() ?? "new"; const pathSegment = location.pathname.split("/").pop() ?? "new";
const tab: InboxTab = pathSegment === "all" ? "all" : "new"; const tab: InboxTab = pathSegment === "all" ? "all" : "new";
@@ -326,7 +368,10 @@ export function Inbox() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const staleIssues = issues ? getStaleIssues(issues) : []; const staleIssues = useMemo(
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
[issues, dismissed],
);
const assignedToMeIssues = useMemo( const assignedToMeIssues = useMemo(
() => () =>
[...assignedToMeIssuesRaw].sort( [...assignedToMeIssuesRaw].sort(
@@ -348,8 +393,8 @@ export function Inbox() {
}, [issues]); }, [issues]);
const failedRuns = useMemo( const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []), () => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns], [heartbeatRuns, dismissed],
); );
const allApprovals = useMemo( const allApprovals = useMemo(
@@ -435,11 +480,12 @@ export function Inbox() {
} }
const hasRunFailures = failedRuns.length > 0; const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures; const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
const showBudgetAlert = const showBudgetAlert =
!!dashboard && !!dashboard &&
dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80; dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
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;
@@ -700,6 +746,7 @@ export function Inbox() {
run={run} run={run}
issueById={issueById} issueById={issueById}
agentName={agentName(run.agentId)} agentName={agentName(run.agentId)}
onDismiss={() => dismiss(`run:${run.id}`)}
/> />
))} ))}
</div> </div>
@@ -716,29 +763,49 @@ export function Inbox() {
</h3> </h3>
<div className="divide-y divide-border border border-border"> <div className="divide-y divide-border border border-border">
{showAggregateAgentError && ( {showAggregateAgentError && (
<Link <div className="group/alert relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
to="/agents" <Link
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit" to="/agents"
> className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
<AlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400" /> >
<span className="text-sm"> <AlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
<span className="font-medium">{dashboard!.agents.error}</span>{" "} <span className="text-sm">
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors <span className="font-medium">{dashboard!.agents.error}</span>{" "}
</span> {dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
</Link> </span>
</Link>
<button
type="button"
onClick={() => dismiss("alert:agent-errors")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)} )}
{showBudgetAlert && ( {showBudgetAlert && (
<Link <div className="group/alert relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
to="/costs" <Link
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit" to="/costs"
> className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" /> >
<span className="text-sm"> <AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
Budget at{" "} <span className="text-sm">
<span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "} Budget at{" "}
utilization this month <span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "}
</span> utilization this month
</Link> </span>
</Link>
<button
type="button"
onClick={() => dismiss("alert:budget")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)} )}
</div> </div>
</div> </div>
@@ -754,33 +821,45 @@ export function Inbox() {
</h3> </h3>
<div className="divide-y divide-border border border-border"> <div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => ( {staleIssues.map((issue) => (
<Link <div
key={issue.id} key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`} className="group/stale relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
> >
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" /> <Link
<PriorityIcon priority={issue.priority} /> to={`/issues/${issue.identifier ?? issue.id}`}
<StatusIcon status={issue.status} /> className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
<span className="text-xs font-mono text-muted-foreground"> >
{issue.identifier ?? issue.id.slice(0, 8)} <Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
</span> <PriorityIcon priority={issue.priority} />
<span className="flex-1 truncate text-sm">{issue.title}</span> <StatusIcon status={issue.status} />
{issue.assigneeAgentId && <span className="text-xs font-mono text-muted-foreground">
(() => { {issue.identifier ?? issue.id.slice(0, 8)}
const name = agentName(issue.assigneeAgentId); </span>
return name ? ( <span className="flex-1 truncate text-sm">{issue.title}</span>
<Identity name={name} size="sm" /> {issue.assigneeAgentId &&
) : ( (() => {
<span className="font-mono text-xs text-muted-foreground"> const name = agentName(issue.assigneeAgentId);
{issue.assigneeAgentId.slice(0, 8)} return name ? (
</span> <Identity name={name} size="sm" />
); ) : (
})()} <span className="font-mono text-xs text-muted-foreground">
<span className="shrink-0 text-xs text-muted-foreground"> {issue.assigneeAgentId.slice(0, 8)}
updated {timeAgo(issue.updatedAt)} </span>
</span> );
</Link> })()}
<span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)}
</span>
</Link>
<button
type="button"
onClick={() => dismiss(`stale:${issue.id}`)}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))} ))}
</div> </div>
</div> </div>