Adjust inbox tab memory and badge counts
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user