Add the inbox mine tab and archive flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 08:19:16 -05:00
parent b34fa3b273
commit 995f5b0b66
21 changed files with 12514 additions and 43 deletions

View file

@ -210,7 +210,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"),
],
unreadIssues: [makeIssue("1", true)],
mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
});
@ -219,7 +219,7 @@ describe("inbox helpers", () => {
approvals: 1,
failedRuns: 2,
joinRequests: 1,
unreadTouchedIssues: 1,
mineIssues: 1,
alerts: 1,
});
});
@ -230,7 +230,7 @@ describe("inbox helpers", () => {
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
unreadIssues: [],
mineIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
});
@ -239,7 +239,7 @@ describe("inbox helpers", () => {
approvals: 0,
failedRuns: 0,
joinRequests: 0,
unreadTouchedIssues: 0,
mineIssues: 0,
alerts: 0,
});
});
@ -262,6 +262,11 @@ describe("inbox helpers", () => {
),
];
expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
@ -338,10 +343,21 @@ describe("inbox helpers", () => {
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({
tab: "mine",
hasItems: true,
showOnMine: true,
showOnRecent: false,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "recent",
hasItems: true,
showOnMine: false,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
@ -351,6 +367,7 @@ describe("inbox helpers", () => {
shouldShowInboxSection({
tab: "unread",
hasItems: true,
showOnMine: true,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
@ -371,16 +388,16 @@ describe("inbox helpers", () => {
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
});
it("defaults the remembered inbox tab to recent and persists all", () => {
it("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("recent");
expect(loadLastInboxTab()).toBe("mine");
saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all");
});
it("maps legacy new-tab storage to recent", () => {
it("maps legacy new-tab storage to mine", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("recent");
expect(loadLastInboxTab()).toBe("mine");
});
});

View file

@ -11,7 +11,7 @@ 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 = "recent" | "unread" | "all";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItem =
| {
@ -40,7 +40,7 @@ export interface InboxBadgeData {
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
mineIssues: number;
alerts: number;
}
@ -64,11 +64,11 @@ export function saveDismissedInboxItems(ids: Set<string>) {
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
if (raw === "new") return "mine";
return "mine";
} catch {
return "recent";
return "mine";
}
}
@ -135,7 +135,7 @@ export function getApprovalsForTab(
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
);
if (tab === "recent") return sortedApprovals;
if (tab === "mine" || tab === "recent") return sortedApprovals;
if (tab === "unread") {
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
}
@ -203,17 +203,20 @@ export function getInboxWorkItems({
export function shouldShowInboxSection({
tab,
hasItems,
showOnMine,
showOnRecent,
showOnUnread,
showOnAll,
}: {
tab: InboxTab;
hasItems: boolean;
showOnMine: boolean;
showOnRecent: boolean;
showOnUnread: boolean;
showOnAll: boolean;
}): boolean {
if (!hasItems) return false;
if (tab === "mine") return showOnMine;
if (tab === "recent") return showOnRecent;
if (tab === "unread") return showOnUnread;
return showOnAll;
@ -224,14 +227,14 @@ export function computeInboxBadgeData({
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
mineIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
unreadIssues: Issue[];
mineIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
@ -240,7 +243,7 @@ export function computeInboxBadgeData({
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const visibleMineIssues = mineIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
@ -255,11 +258,11 @@ export function computeInboxBadgeData({
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
inbox: actionableApprovals + joinRequests.length + failedRuns + visibleMineIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
mineIssues: visibleMineIssues,
alerts,
};
}

View file

@ -31,6 +31,7 @@ export const queryKeys = {
search: (companyId: string, q: string, projectId?: string) =>
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
labels: (companyId: string) => ["issues", companyId, "labels"] as const,