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 { queryKeys } from "./lib/queryKeys";
|
||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
|
|
||||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -138,7 +139,7 @@ function boardRoutes() {
|
|||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="costs" element={<Costs />} />
|
||||||
<Route path="activity" element={<Activity />} />
|
<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/new" element={<Inbox />} />
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
@@ -147,6 +148,10 @@ function boardRoutes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InboxRootRedirect() {
|
||||||
|
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function CompanyRootRedirect() {
|
function CompanyRootRedirect() {
|
||||||
const { companies, selectedCompany, loading } = useCompany();
|
const { companies, selectedCompany, loading } = useCompany();
|
||||||
const { onboardingOpen } = useDialog();
|
const { onboardingOpen } = useDialog();
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function CommandPalette() {
|
|||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => go("/inbox/all")}>
|
<CommandItem onSelect={() => go("/inbox")}>
|
||||||
<Inbox className="mr-2 h-4 w-4" />
|
<Inbox className="mr-2 h-4 w-4" />
|
||||||
Inbox
|
Inbox
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
to: "/inbox/all",
|
to: "/inbox",
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
badge: inboxBadge.inbox,
|
badge: inboxBadge.inbox,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/inbox/all"
|
to="/inbox"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
badge={inboxBadge.inbox}
|
badge={inboxBadge.inbox}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
saveDismissedInboxItems,
|
saveDismissedInboxItems,
|
||||||
} from "../lib/inbox";
|
} 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() {
|
export function useDismissedInboxItems() {
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
||||||
@@ -70,12 +70,12 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: touchedIssues = [] } = useQuery({
|
const { data: unreadIssues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
issuesApi.list(companyId!, {
|
issuesApi.list(companyId!, {
|
||||||
touchedByUserId: "me",
|
unreadForUserId: "me",
|
||||||
status: TOUCHED_ISSUE_STATUSES,
|
status: INBOX_ISSUE_STATUSES,
|
||||||
}),
|
}),
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
@@ -93,9 +93,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||||||
joinRequests,
|
joinRequests,
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
touchedIssues,
|
unreadIssues,
|
||||||
dismissed,
|
dismissed,
|
||||||
}),
|
}),
|
||||||
[approvals, joinRequests, dashboard, heartbeatRuns, touchedIssues, dismissed],
|
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
// @vitest-environment node
|
// @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 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 {
|
function makeApproval(status: Approval["status"]): Approval {
|
||||||
return {
|
return {
|
||||||
@@ -142,6 +165,10 @@ const dashboard: DashboardSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("inbox helpers", () => {
|
describe("inbox helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it("counts the same inbox sources the badge uses", () => {
|
it("counts the same inbox sources the badge uses", () => {
|
||||||
const result = computeInboxBadgeData({
|
const result = computeInboxBadgeData({
|
||||||
approvals: [makeApproval("pending"), makeApproval("approved")],
|
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-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
|
||||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
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>(),
|
dismissed: new Set<string>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +199,7 @@ describe("inbox helpers", () => {
|
|||||||
joinRequests: [],
|
joinRequests: [],
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
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"]),
|
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(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
|
||||||
expect(issues).toHaveLength(2);
|
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 FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
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 {
|
export interface InboxBadgeData {
|
||||||
inbox: number;
|
inbox: number;
|
||||||
@@ -30,7 +32,28 @@ export function loadDismissedInboxItems(): Set<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveDismissedInboxItems(ids: Set<string>) {
|
export function saveDismissedInboxItems(ids: Set<string>) {
|
||||||
|
try {
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
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[] {
|
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||||
@@ -80,14 +103,14 @@ export function computeInboxBadgeData({
|
|||||||
joinRequests,
|
joinRequests,
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
touchedIssues,
|
unreadIssues,
|
||||||
dismissed,
|
dismissed,
|
||||||
}: {
|
}: {
|
||||||
approvals: Approval[];
|
approvals: Approval[];
|
||||||
joinRequests: JoinRequest[];
|
joinRequests: JoinRequest[];
|
||||||
dashboard: DashboardSummary | undefined;
|
dashboard: DashboardSummary | undefined;
|
||||||
heartbeatRuns: HeartbeatRun[];
|
heartbeatRuns: HeartbeatRun[];
|
||||||
touchedIssues: Issue[];
|
unreadIssues: Issue[];
|
||||||
dismissed: Set<string>;
|
dismissed: Set<string>;
|
||||||
}): InboxBadgeData {
|
}): InboxBadgeData {
|
||||||
const actionableApprovals = approvals.filter((approval) =>
|
const actionableApprovals = approvals.filter((approval) =>
|
||||||
@@ -96,7 +119,7 @@ export function computeInboxBadgeData({
|
|||||||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||||
(run) => !dismissed.has(`run:${run.id}`),
|
(run) => !dismissed.has(`run:${run.id}`),
|
||||||
).length;
|
).length;
|
||||||
const unreadTouchedIssues = getUnreadTouchedIssues(touchedIssues).length;
|
const unreadTouchedIssues = unreadIssues.length;
|
||||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||||
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
|
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
|
||||||
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
|
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
|||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
|
type InboxTab,
|
||||||
normalizeTimestamp,
|
normalizeTimestamp,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
saveLastInboxTab,
|
||||||
sortIssuesByMostRecentActivity,
|
sortIssuesByMostRecentActivity,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
type InboxTab = "new" | "all";
|
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
@@ -265,6 +266,10 @@ export function Inbox() {
|
|||||||
setBreadcrumbs([{ label: "Inbox" }]);
|
setBreadcrumbs([{ label: "Inbox" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveLastInboxTab(tab);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: approvals,
|
data: approvals,
|
||||||
isLoading: isApprovalsLoading,
|
isLoading: isApprovalsLoading,
|
||||||
@@ -328,10 +333,6 @@ export function Inbox() {
|
|||||||
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||||
[touchedIssuesRaw],
|
[touchedIssuesRaw],
|
||||||
);
|
);
|
||||||
const unreadTouchedIssues = useMemo(
|
|
||||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
|
||||||
[touchedIssues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -466,7 +467,7 @@ export function Inbox() {
|
|||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = unreadTouchedIssues.length > 0;
|
const hasTouchedIssues = touchedIssues.length > 0;
|
||||||
|
|
||||||
const showJoinRequestsCategory =
|
const showJoinRequestsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
@@ -749,7 +750,7 @@ export function Inbox() {
|
|||||||
My Recent Issues
|
My Recent Issues
|
||||||
</h3>
|
</h3>
|
||||||
<div className="divide-y divide-border border border-border">
|
<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 isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user