Files
paperclip/ui/src/lib/inbox.ts
2026-03-11 09:02:23 -05:00

151 lines
4.5 KiB
TypeScript

import type {
Approval,
DashboardSummary,
HeartbeatRun,
Issue,
JoinRequest,
} from "@paperclipai/shared";
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 = "recent" | "unread" | "all";
export interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
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);
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
} catch {
return "recent";
}
}
export function saveLastInboxTab(tab: InboxTab) {
try {
localStorage.setItem(INBOX_LAST_TAB_KEY, tab);
} catch {
// Ignore localStorage failures.
}
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
}
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
}
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
unreadIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
alerts,
};
}