diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8f1b3ef9..f51679de 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,7 @@ import { NotFoundPage } from "./pages/NotFound"; import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; +import { loadLastInboxTab } from "./lib/inbox"; function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -138,7 +139,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -147,6 +148,10 @@ function boardRoutes() { ); } +function InboxRootRedirect() { + return ; +} + function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialog(); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 04b4b035..3defb0e6 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -142,7 +142,7 @@ export function CommandPalette() { Dashboard - go("/inbox/all")}> + go("/inbox")}> Inbox diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index 7ba83b89..daa17318 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -47,7 +47,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { { type: "link", to: "/agents/all", label: "Agents", icon: Users }, { type: "link", - to: "/inbox/all", + to: "/inbox", label: "Inbox", icon: Inbox, badge: inboxBadge.inbox, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 684c878a..0cc46d87 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -73,7 +73,7 @@ export function Sidebar() { >(loadDismissedInboxItems); @@ -70,12 +70,12 @@ export function useInboxBadge(companyId: string | null | undefined) { enabled: !!companyId, }); - const { data: touchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.listTouchedByMe(companyId!), + const { data: unreadIssues = [] } = useQuery({ + queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!), queryFn: () => issuesApi.list(companyId!, { - touchedByUserId: "me", - status: TOUCHED_ISSUE_STATUSES, + unreadForUserId: "me", + status: INBOX_ISSUE_STATUSES, }), enabled: !!companyId, }); @@ -93,9 +93,9 @@ export function useInboxBadge(companyId: string | null | undefined) { joinRequests, dashboard, heartbeatRuns, - touchedIssues, + unreadIssues, dismissed, }), - [approvals, joinRequests, dashboard, heartbeatRuns, touchedIssues, dismissed], + [approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed], ); } diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 10183cf2..016ad9e3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1,8 +1,31 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; -import { computeInboxBadgeData, getUnreadTouchedIssues } from "./inbox"; +import { + computeInboxBadgeData, + getUnreadTouchedIssues, + loadLastInboxTab, + saveLastInboxTab, +} from "./inbox"; + +const storage = new Map(); + +Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + }, + configurable: true, +}); function makeApproval(status: Approval["status"]): Approval { return { @@ -142,6 +165,10 @@ const dashboard: DashboardSummary = { }; describe("inbox helpers", () => { + beforeEach(() => { + storage.clear(); + }); + it("counts the same inbox sources the badge uses", () => { const result = computeInboxBadgeData({ approvals: [makeApproval("pending"), makeApproval("approved")], @@ -152,7 +179,7 @@ describe("inbox helpers", () => { makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"), makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), ], - touchedIssues: [makeIssue("1", true), makeIssue("2", false)], + unreadIssues: [makeIssue("1", true)], dismissed: new Set(), }); @@ -172,7 +199,7 @@ describe("inbox helpers", () => { joinRequests: [], dashboard, heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], - touchedIssues: [], + unreadIssues: [], dismissed: new Set(["run:run-1", "alert:budget", "alert:agent-errors"]), }); @@ -192,4 +219,12 @@ describe("inbox helpers", () => { expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]); expect(issues).toHaveLength(2); }); + + it("defaults the remembered inbox tab to new and persists all", () => { + localStorage.clear(); + expect(loadLastInboxTab()).toBe("new"); + + saveLastInboxTab("all"); + expect(loadLastInboxTab()).toBe("all"); + }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 3ae9f9a9..635991d4 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -10,6 +10,8 @@ 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 INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; +export type InboxTab = "new" | "all"; export interface InboxBadgeData { inbox: number; @@ -30,7 +32,28 @@ export function loadDismissedInboxItems(): Set { } export function saveDismissedInboxItems(ids: Set) { - localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); + try { + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); + } catch { + // Ignore localStorage failures. + } +} + +export function loadLastInboxTab(): InboxTab { + try { + const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); + return raw === "all" ? "all" : "new"; + } catch { + return "new"; + } +} + +export function saveLastInboxTab(tab: InboxTab) { + try { + localStorage.setItem(INBOX_LAST_TAB_KEY, tab); + } catch { + // Ignore localStorage failures. + } } export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { @@ -80,14 +103,14 @@ export function computeInboxBadgeData({ joinRequests, dashboard, heartbeatRuns, - touchedIssues, + unreadIssues, dismissed, }: { approvals: Approval[]; joinRequests: JoinRequest[]; dashboard: DashboardSummary | undefined; heartbeatRuns: HeartbeatRun[]; - touchedIssues: Issue[]; + unreadIssues: Issue[]; dismissed: Set; }): InboxBadgeData { const actionableApprovals = approvals.filter((approval) => @@ -96,7 +119,7 @@ export function computeInboxBadgeData({ const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( (run) => !dismissed.has(`run:${run.id}`), ).length; - const unreadTouchedIssues = getUnreadTouchedIssues(touchedIssues).length; + const unreadTouchedIssues = unreadIssues.length; const agentErrorCount = dashboard?.agents.error ?? 0; const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3858e88f..1a87b97a 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -43,13 +43,14 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, + type InboxTab, normalizeTimestamp, RECENT_ISSUES_LIMIT, + saveLastInboxTab, sortIssuesByMostRecentActivity, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; -type InboxTab = "new" | "all"; type InboxCategoryFilter = | "everything" | "issues_i_touched" @@ -265,6 +266,10 @@ export function Inbox() { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); + useEffect(() => { + saveLastInboxTab(tab); + }, [tab]); + const { data: approvals, isLoading: isApprovalsLoading, @@ -328,10 +333,6 @@ export function Inbox() { () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), [touchedIssuesRaw], ); - const unreadTouchedIssues = useMemo( - () => touchedIssues.filter((issue) => issue.isUnreadForMe), - [touchedIssues], - ); const agentById = useMemo(() => { const map = new Map(); @@ -466,7 +467,7 @@ export function Inbox() { !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = unreadTouchedIssues.length > 0; + const hasTouchedIssues = touchedIssues.length > 0; const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; @@ -749,7 +750,7 @@ export function Inbox() { My Recent Issues
- {(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => { + {touchedIssues.map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return (