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 READ_ITEMS_KEY = "paperclip:inbox:read-items"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns"; export type InboxTab = "mine" | "recent" | "unread" | "all"; export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const; export type InboxIssueColumn = (typeof inboxIssueColumns)[number]; export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"]; export type InboxWorkItem = | { kind: "issue"; timestamp: number; issue: Issue; } | { kind: "approval"; timestamp: number; approval: Approval; } | { kind: "failed_run"; timestamp: number; run: HeartbeatRun; } | { kind: "join_request"; timestamp: number; joinRequest: JoinRequest; }; export interface InboxBadgeData { inbox: number; approvals: number; failedRuns: number; joinRequests: number; mineIssues: number; alerts: number; } export function loadDismissedInboxItems(): Set { 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) { try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); } catch { // Ignore localStorage failures. } } export function loadReadInboxItems(): Set { try { const raw = localStorage.getItem(READ_ITEMS_KEY); return raw ? new Set(JSON.parse(raw)) : new Set(); } catch { return new Set(); } } export function saveReadInboxItems(ids: Set) { try { localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids])); } catch { // Ignore localStorage failures. } } export function normalizeInboxIssueColumns(columns: Iterable): InboxIssueColumn[] { const selected = new Set(columns); return inboxIssueColumns.filter((column) => selected.has(column)); } export function getAvailableInboxIssueColumns(enableWorkspaceColumn: boolean): InboxIssueColumn[] { if (enableWorkspaceColumn) return [...inboxIssueColumns]; return inboxIssueColumns.filter((column) => column !== "workspace"); } export function loadInboxIssueColumns(): InboxIssueColumn[] { try { const raw = localStorage.getItem(INBOX_ISSUE_COLUMNS_KEY); if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS; return normalizeInboxIssueColumns(parsed); } catch { return DEFAULT_INBOX_ISSUE_COLUMNS; } } export function saveInboxIssueColumns(columns: InboxIssueColumn[]) { try { localStorage.setItem( INBOX_ISSUE_COLUMNS_KEY, JSON.stringify(normalizeInboxIssueColumns(columns)), ); } catch { // Ignore localStorage failures. } } export function resolveIssueWorkspaceName( issue: Pick, { executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, }: { executionWorkspaceById?: ReadonlyMap; projectWorkspaceById?: ReadonlyMap; defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; }, ): string | null { const defaultProjectWorkspaceId = issue.projectId ? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null : null; if (issue.executionWorkspaceId) { const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null; const linkedProjectWorkspaceId = executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null; const isDefaultSharedExecutionWorkspace = executionWorkspace?.mode === "shared_workspace" && linkedProjectWorkspaceId === defaultProjectWorkspaceId; if (isDefaultSharedExecutionWorkspace) return null; const workspaceName = executionWorkspace?.name; if (workspaceName) return workspaceName; } if (issue.projectWorkspaceId) { if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null; const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name; if (workspaceName) return workspaceName; } return null; } export function loadLastInboxTab(): InboxTab { try { const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw; if (raw === "new") return "mine"; return "mine"; } catch { return "mine"; } } export function saveLastInboxTab(tab: InboxTab) { try { localStorage.setItem(INBOX_LAST_TAB_KEY, tab); } catch { // Ignore localStorage failures. } } export function isMineInboxTab(tab: InboxTab): boolean { return tab === "mine"; } export function resolveInboxSelectionIndex( previousIndex: number, itemCount: number, ): number { if (itemCount === 0) return -1; if (previousIndex < 0) return -1; return Math.min(previousIndex, itemCount - 1); } export function getInboxKeyboardSelectionIndex( previousIndex: number, itemCount: number, direction: "next" | "previous", ): number { if (itemCount === 0) return -1; if (previousIndex < 0) return 0; return direction === "next" ? Math.min(previousIndex + 1, itemCount - 1) : Math.max(previousIndex - 1, 0); } 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(); 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 lastActivityAt = normalizeTimestamp(issue.lastActivityAt); if (lastActivityAt > 0) return lastActivityAt; const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt); if (lastExternalCommentAt > 0) return lastExternalCommentAt; return normalizeTimestamp(issue.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 getApprovalsForTab( approvals: Approval[], tab: InboxTab, filter: InboxApprovalFilter, ): Approval[] { const sortedApprovals = [...approvals].sort( (a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt), ); if (tab === "mine" || tab === "recent") return sortedApprovals; if (tab === "unread") { return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)); } if (filter === "all") return sortedApprovals; return sortedApprovals.filter((approval) => { const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); return filter === "actionable" ? isActionable : !isActionable; }); } export function approvalActivityTimestamp(approval: Approval): number { const updatedAt = normalizeTimestamp(approval.updatedAt); if (updatedAt > 0) return updatedAt; return normalizeTimestamp(approval.createdAt); } export function getInboxWorkItems({ issues, approvals, failedRuns = [], joinRequests = [], }: { issues: Issue[]; approvals: Approval[]; failedRuns?: HeartbeatRun[]; joinRequests?: JoinRequest[]; }): InboxWorkItem[] { return [ ...issues.map((issue) => ({ kind: "issue" as const, timestamp: issueLastActivityTimestamp(issue), issue, })), ...approvals.map((approval) => ({ kind: "approval" as const, timestamp: approvalActivityTimestamp(approval), approval, })), ...failedRuns.map((run) => ({ kind: "failed_run" as const, timestamp: normalizeTimestamp(run.createdAt), run, })), ...joinRequests.map((joinRequest) => ({ kind: "join_request" as const, timestamp: normalizeTimestamp(joinRequest.createdAt), joinRequest, })), ].sort((a, b) => { const timestampDiff = b.timestamp - a.timestamp; if (timestampDiff !== 0) return timestampDiff; if (a.kind === "issue" && b.kind === "issue") { return sortIssuesByMostRecentActivity(a.issue, b.issue); } if (a.kind === "approval" && b.kind === "approval") { return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval); } return a.kind === "approval" ? -1 : 1; }); } 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; } export function computeInboxBadgeData({ approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed, }: { approvals: Approval[]; joinRequests: JoinRequest[]; dashboard: DashboardSummary | undefined; heartbeatRuns: HeartbeatRun[]; mineIssues: Issue[]; dismissed: Set; }): InboxBadgeData { const actionableApprovals = approvals.filter( (approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && !dismissed.has(`approval:${approval.id}`), ).length; const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( (run) => !dismissed.has(`run:${run.id}`), ).length; const visibleJoinRequests = joinRequests.filter( (jr) => !dismissed.has(`join:${jr.id}`), ).length; const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).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 + visibleJoinRequests + failedRuns + visibleMineIssues + alerts, approvals: actionableApprovals, failedRuns, joinRequests: visibleJoinRequests, mineIssues: visibleMineIssues, alerts, }; }