From 2c406d3b8ce4289b39fbd03ca3bc731764f03599 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 10:16:19 -0500 Subject: [PATCH] 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 --- ui/src/hooks/useInboxBadge.ts | 27 +++ ui/src/lib/inbox.ts | 31 +++- ui/src/pages/Inbox.tsx | 309 ++++++++++++++++++++++++++++++---- 3 files changed, 327 insertions(+), 40 deletions(-) diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 8f8292bf..50f4323d 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -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>(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(); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 0de7f053..3b4297b8 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -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) { } } +export function loadReadInboxItems(): Set { + 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) { + 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; }): 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, }; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 5564e004..521bdf58 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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; @@ -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 ( -
+
{isRetrying ? "Retrying…" : "Retry"} - + {!showUnreadSlot && ( + + )}
+ {showUnreadSlot && ( + + {showUnreadDot ? ( + + ) : onArchive ? ( + + ) : ( + + )}
- + {!showUnreadSlot && ( + + )}
); @@ -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 | 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 ( -
+
) : null} + {showUnreadSlot && ( + + {showUnreadDot ? ( + + ) : onArchive ? ( + + ) : ( + + )}
{showResolutionButtons ? (
@@ -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 ( -
+
+ {showUnreadSlot && ( + + {showUnreadDot ? ( + + ) : onArchive ? ( + + ) : ( + + )}