Extend read/dismissed functionality to all inbox item types

Approvals, failed runs, and join requests now have the same
unread dot + archive X pattern as issues in the Mine tab:
- Click the blue dot to mark as read, then X appears on hover
- Desktop: animated dismiss with scale/slide transition
- Mobile: swipe-to-archive via SwipeToArchive wrapper
- Dismissed items are filtered out of Mine tab
- Badge count excludes dismissed approvals and join requests
- localStorage-backed read/dismiss state for non-issue items

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 10:16:19 -05:00
parent 49c7fb7fbd
commit 2c406d3b8c
3 changed files with 327 additions and 40 deletions

View file

@ -12,6 +12,9 @@ import {
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
@ -40,6 +43,30 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss };
}
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== READ_ITEMS_KEY) return;
setReadItems(loadReadInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const markRead = (id: string) => {
setReadItems((prev) => {
const next = new Set(prev);
next.add(id);
saveReadInboxItems(next);
return next;
});
};
return { readItems, markRead };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();

View file

@ -10,6 +10,7 @@ export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
@ -61,6 +62,23 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveReadInboxItems(ids: Set<string>) {
try {
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
@ -237,12 +255,17 @@ export function computeInboxBadgeData({
mineIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
).length;
const visibleMineIssues = mineIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
@ -258,10 +281,10 @@ export function computeInboxBadgeData({
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + visibleMineIssues + alerts,
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
joinRequests: visibleJoinRequests,
mineIssues: visibleMineIssues,
alerts,
};

View file

@ -18,6 +18,7 @@ import { IssueRow } from "../components/IssueRow";
import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon";
import { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
@ -52,7 +53,7 @@ import {
shouldShowInboxSection,
type InboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
type InboxCategoryFilter =
| "everything"
@ -95,6 +96,8 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
}
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
function FailedRunInboxRow({
run,
issueById,
@ -103,6 +106,11 @@ function FailedRunInboxRow({
onDismiss,
onRetry,
isRetrying,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
@ -111,13 +119,23 @@ function FailedRunInboxRow({
onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const displayError = runFailureMessage(run);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
@ -161,15 +179,46 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="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>
{!showUnreadSlot && (
<button
type="button"
onClick={onDismiss}
className="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>
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onArchive(); }}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
)}
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
@ -183,14 +232,16 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
{!showUnreadSlot && (
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
@ -202,21 +253,36 @@ function ApprovalInboxRow({
onApprove,
onReject,
isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
approval: Approval;
requesterName: string | null;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons =
approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/approvals/${approval.id}`}
@ -259,6 +325,35 @@ function ApprovalInboxRow({
</Button>
</div>
) : null}
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onArchive(); }}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
)}
</div>
{showResolutionButtons ? (
<div className="mt-3 flex gap-2 sm:hidden">
@ -290,19 +385,34 @@ function JoinRequestInboxRow({
onApprove,
onReject,
isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
joinRequest: JoinRequest;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const label =
joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -339,6 +449,35 @@ function JoinRequestInboxRow({
Reject
</Button>
</div>
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onArchive(); }}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
)}
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
@ -373,6 +512,7 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
@ -515,10 +655,13 @@ export function Inbox() {
return ids;
}, [heartbeatRuns]);
const approvalsToRender = useMemo(
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
[approvals, tab, allApprovalFilter],
);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
if (tab === "mine") {
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@ -535,11 +678,9 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "mine") return joinRequests;
if (tab === "recent") return joinRequests;
if (tab === "unread") return joinRequests;
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory]);
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
const workItemsToRender = useMemo(
() =>
@ -650,6 +791,8 @@ export function Inbox() {
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return;
@ -732,6 +875,39 @@ export function Inbox() {
},
});
const handleMarkNonIssueRead = (key: string) => {
setFadingNonIssueItems((prev) => new Set(prev).add(key));
markItemRead(key);
setTimeout(() => {
setFadingNonIssueItems((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 300);
};
const handleArchiveNonIssue = (key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => {
dismiss(key);
setArchivingNonIssueIds((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 200);
};
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (tab !== "mine") return null;
const isRead = readItems.has(key);
const isFading = fadingNonIssueItems.has(key);
if (isFading) return "fading";
if (!isRead) return "visible";
return "hidden";
};
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@ -876,50 +1052,111 @@ export function Inbox() {
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => {
const isMineTab = tab === "mine";
if (item.kind === "approval") {
return (
const approvalKey = `approval:${item.approval.id}`;
const isArchiving = archivingNonIssueIds.has(approvalKey);
const row = (
<ApprovalInboxRow
key={`approval:${item.approval.id}`}
key={approvalKey}
approval={item.approval}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending}
unreadState={nonIssueUnreadState(approvalKey)}
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={approvalKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
{row}
</SwipeToArchive>
) : row;
}
if (item.kind === "failed_run") {
return (
const runKey = `run:${item.run.id}`;
const isArchiving = archivingNonIssueIds.has(runKey);
const row = (
<FailedRunInboxRow
key={`run:${item.run.id}`}
key={runKey}
run={item.run}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)}
onDismiss={() => dismiss(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
onMarkRead={() => handleMarkNonIssueRead(runKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={runKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
{row}
</SwipeToArchive>
) : row;
}
if (item.kind === "join_request") {
return (
const joinKey = `join:${item.joinRequest.id}`;
const isArchiving = archivingNonIssueIds.has(joinKey);
const row = (
<JoinRequestInboxRow
key={`join:${item.joinRequest.id}`}
key={joinKey}
joinRequest={item.joinRequest}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
unreadState={nonIssueUnreadState(joinKey)}
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={joinKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
{row}
</SwipeToArchive>
) : row;
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
const isMineTab = tab === "mine";
const isArchiving = archivingIssueIds.has(issue.id);
const row = (
<IssueRow