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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user