Adjust inbox tab memory and badge counts

This commit is contained in:
Dotta
2026-03-11 07:42:19 -05:00
parent 21d2b075e7
commit a503d2c12c
8 changed files with 90 additions and 26 deletions

View File

@@ -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() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/all" replace />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="design-guide" element={<DesignGuide />} />
@@ -147,6 +148,10 @@ function boardRoutes() {
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();

View File

@@ -142,7 +142,7 @@ export function CommandPalette() {
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => go("/inbox/all")}>
<CommandItem onSelect={() => go("/inbox")}>
<Inbox className="mr-2 h-4 w-4" />
Inbox
</CommandItem>

View File

@@ -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,

View File

@@ -73,7 +73,7 @@ export function Sidebar() {
</button>
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem
to="/inbox/all"
to="/inbox"
label="Inbox"
icon={Inbox}
badge={inboxBadge.inbox}

View File

@@ -13,7 +13,7 @@ import {
saveDismissedInboxItems,
} from "../lib/inbox";
const TOUCHED_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(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],
);
}

View File

@@ -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<string, string>();
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<string>(),
});
@@ -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<string>(["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");
});
});

View File

@@ -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<string> {
}
export function saveDismissedInboxItems(ids: Set<string>) {
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<string>;
}): 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;

View File

@@ -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<string, string>();
@@ -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
</h3>
<div className="divide-y divide-border border border-border">
{(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => {
{touchedIssues.map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (