diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 9b7edb06..0576dcf2 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -3,12 +3,14 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { + getApprovalsForTab, computeInboxBadgeData, getRecentTouchedIssues, getUnreadTouchedIssues, loadLastInboxTab, RECENT_ISSUES_LIMIT, saveLastInboxTab, + shouldShowInboxSection, } from "./inbox"; const storage = new Map(); @@ -46,6 +48,19 @@ function makeApproval(status: Approval["status"]): Approval { }; } +function makeApprovalWithTimestamps( + id: string, + status: Approval["status"], + updatedAt: string, +): Approval { + return { + ...makeApproval(status), + id, + createdAt: new Date(updatedAt), + updatedAt: new Date(updatedAt), + }; +} + function makeJoinRequest(id: string): JoinRequest { return { id, @@ -231,6 +246,52 @@ describe("inbox helpers", () => { expect(issues).toHaveLength(2); }); + it("shows recent approvals in updated order and unread approvals as actionable only", () => { + const approvals = [ + makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"), + makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"), + makeApprovalWithTimestamps( + "approval-revision", + "revision_requested", + "2026-03-11T03:00:00.000Z", + ), + ]; + + expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([ + "approval-revision", + "approval-approved", + "approval-pending", + ]); + expect(getApprovalsForTab(approvals, "unread", "all").map((approval) => approval.id)).toEqual([ + "approval-revision", + "approval-pending", + ]); + expect(getApprovalsForTab(approvals, "all", "resolved").map((approval) => approval.id)).toEqual([ + "approval-approved", + ]); + }); + + it("can include sections on recent without forcing them to be unread", () => { + expect( + shouldShowInboxSection({ + tab: "recent", + hasItems: true, + showOnRecent: true, + showOnUnread: false, + showOnAll: false, + }), + ).toBe(true); + expect( + shouldShowInboxSection({ + tab: "unread", + hasItems: true, + showOnRecent: true, + showOnUnread: false, + showOnAll: false, + }), + ).toBe(false); + }); + it("limits recent touched issues before unread badge counting", () => { const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => { const issue = makeIssue(String(index + 1), index < 3); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 88447a98..9edba8ac 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -12,6 +12,7 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export type InboxTab = "recent" | "unread" | "all"; +export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export interface InboxBadgeData { inbox: number; @@ -104,6 +105,46 @@ export function getUnreadTouchedIssues(issues: Issue[]): Issue[] { return issues.filter((issue) => issue.isUnreadForMe); } +export function getApprovalsForTab( + approvals: Approval[], + tab: InboxTab, + filter: InboxApprovalFilter, +): Approval[] { + const sortedApprovals = [...approvals].sort( + (a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt), + ); + + if (tab === "recent") return sortedApprovals; + if (tab === "unread") { + return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)); + } + if (filter === "all") return sortedApprovals; + + return sortedApprovals.filter((approval) => { + const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); + return filter === "actionable" ? isActionable : !isActionable; + }); +} + +export function shouldShowInboxSection({ + tab, + hasItems, + showOnRecent, + showOnUnread, + showOnAll, +}: { + tab: InboxTab; + hasItems: boolean; + showOnRecent: boolean; + showOnUnread: boolean; + showOnAll: boolean; +}): boolean { + if (!hasItems) return false; + if (tab === "recent") return showOnRecent; + if (tab === "unread") return showOnUnread; + return showOnAll; +} + export function computeInboxBadgeData({ approvals, joinRequests, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index a00cc9b8..bf580f33 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -43,10 +43,13 @@ import { PageTabBar } from "../components/PageTabBar"; import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, + getApprovalsForTab, getLatestFailedRunsByAgent, getRecentTouchedIssues, + InboxApprovalFilter, type InboxTab, saveLastInboxTab, + shouldShowInboxSection, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; @@ -57,7 +60,6 @@ type InboxCategoryFilter = | "approvals" | "failed_runs" | "alerts"; -type InboxApprovalFilter = "all" | "actionable" | "resolved"; type SectionKey = | "issues_i_touched" | "join_requests" @@ -362,10 +364,7 @@ export function Inbox() { }, [heartbeatRuns]); const allApprovals = useMemo( - () => - [...(approvals ?? [])].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ), + () => getApprovalsForTab(approvals ?? [], "recent", "all"), [approvals], ); @@ -374,14 +373,10 @@ export function Inbox() { [allApprovals], ); - const filteredAllApprovals = useMemo(() => { - if (allApprovalFilter === "all") return allApprovals; - - return allApprovals.filter((approval) => { - const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); - return allApprovalFilter === "actionable" ? isActionable : !isActionable; - }); - }, [allApprovals, allApprovalFilter]); + const approvalsToRender = useMemo( + () => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter), + [approvals, tab, allApprovalFilter], + ); const agentName = (id: string | null) => { if (!id) return null; @@ -516,21 +511,36 @@ export function Inbox() { allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals; - const showTouchedSection = - tab === "all" - ? showTouchedCategory && hasTouchedIssues - : tab === "unread" - ? unreadTouchedIssues.length > 0 - : hasTouchedIssues; + const showTouchedSection = shouldShowInboxSection({ + tab, + hasItems: tab === "unread" ? unreadTouchedIssues.length > 0 : hasTouchedIssues, + showOnRecent: hasTouchedIssues, + showOnUnread: unreadTouchedIssues.length > 0, + showOnAll: showTouchedCategory && hasTouchedIssues, + }); const showJoinRequestsSection = tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; - const showApprovalsSection = tab === "all" - ? showApprovalsCategory && filteredAllApprovals.length > 0 - : actionableApprovals.length > 0; - const showFailedRunsSection = - tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures; - const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts; + const showApprovalsSection = shouldShowInboxSection({ + tab, + hasItems: approvalsToRender.length > 0, + showOnRecent: approvalsToRender.length > 0, + showOnUnread: actionableApprovals.length > 0, + showOnAll: showApprovalsCategory && approvalsToRender.length > 0, + }); + const showFailedRunsSection = shouldShowInboxSection({ + tab, + hasItems: hasRunFailures, + showOnRecent: hasRunFailures, + showOnUnread: hasRunFailures, + showOnAll: showFailedRunsCategory && hasRunFailures, + }); + const showAlertsSection = shouldShowInboxSection({ + tab, + hasItems: hasAlerts, + showOnRecent: hasAlerts, + showOnUnread: hasAlerts, + showOnAll: showAlertsCategory && hasAlerts, + }); const visibleSections = [ showFailedRunsSection ? "failed_runs" : null,