import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; import { ApiError } from "../api/client"; import { dashboardApi } from "../api/dashboard"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { ApprovalCard } from "../components/ApprovalCard"; import { StatusBadge } from "../components/StatusBadge"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tabs } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Inbox as InboxIcon, AlertTriangle, Clock, ArrowUpRight, XCircle, X, RotateCcw, } from "lucide-react"; import { Identity } from "../components/Identity"; import { PageTabBar } from "../components/PageTabBar"; import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours const RECENT_ISSUES_LIMIT = 100; const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); type InboxTab = "new" | "all"; type InboxCategoryFilter = | "everything" | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work"; type InboxApprovalFilter = "all" | "actionable" | "resolved"; type SectionKey = | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts" | "stale_work"; const DISMISSED_KEY = "paperclip:inbox:dismissed"; function loadDismissed(): Set { try { const raw = localStorage.getItem(DISMISSED_KEY); return raw ? new Set(JSON.parse(raw)) : new Set(); } catch { return new Set(); } } function saveDismissed(ids: Set) { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); } function useDismissedItems() { const [dismissed, setDismissed] = useState>(loadDismissed); const dismiss = useCallback((id: string) => { setDismissed((prev) => { const next = new Set(prev); next.add(id); saveDismissed(next); return next; }); }, []); return { dismissed, dismiss }; } const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", assignment: "Assignment", on_demand: "Manual", automation: "Automation", }; function getStaleIssues(issues: Issue[]): Issue[] { const now = Date.now(); return issues .filter( (i) => ["in_progress", "todo"].includes(i.status) && now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS, ) .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); } 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)); } function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); return line ?? null; } function runFailureMessage(run: HeartbeatRun): string { return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error."; } function normalizeTimestamp(value: string | Date | null | undefined): number { if (!value) return 0; const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; } 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; } function readIssueIdFromRun(run: HeartbeatRun): string | null { const context = run.contextSnapshot; if (!context) return null; const issueId = context["issueId"]; if (typeof issueId === "string" && issueId.length > 0) return issueId; const taskId = context["taskId"]; if (typeof taskId === "string" && taskId.length > 0) return taskId; return null; } function FailedRunCard({ run, issueById, agentName: linkedAgentName, onDismiss, }: { run: HeartbeatRun; issueById: Map; agentName: string | null; onDismiss: () => void; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const issueId = readIssueIdFromRun(run); const issue = issueId ? issueById.get(issueId) ?? null : null; const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual"; const displayError = runFailureMessage(run); const retryRun = useMutation({ mutationFn: async () => { const payload: Record = {}; const context = run.contextSnapshot as Record | null; if (context) { if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId; if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId; if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey; } const result = await agentsApi.wakeup(run.agentId, { source: "on_demand", triggerDetail: "manual", reason: "retry_failed_run", payload, }); if (!("id" in result)) { throw new Error("Retry was skipped because the agent is not currently invokable."); } return result; }, onSuccess: (newRun) => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); navigate(`/agents/${run.agentId}/runs/${newRun.id}`); }, }); return (
{issue ? ( {issue.identifier ?? issue.id.slice(0, 8)} {issue.title} ) : ( {run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"} )}
{linkedAgentName ? ( ) : ( Agent {run.agentId.slice(0, 8)} )}

{sourceLabel} run failed {timeAgo(run.createdAt)}

{displayError}
run {run.id.slice(0, 8)}
{retryRun.isError && (
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
)}
); } export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); const [allApprovalFilter, setAllApprovalFilter] = useState("all"); const { dismissed, dismiss } = useDismissedItems(); const pathSegment = location.pathname.split("/").pop() ?? "new"; const tab: InboxTab = pathSegment === "all" ? "all" : "new"; const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); const { data: approvals, isLoading: isApprovalsLoading, error: approvalsError, } = useQuery({ queryKey: queryKeys.approvals.list(selectedCompanyId!), queryFn: () => approvalsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: joinRequests = [], isLoading: isJoinRequestsLoading, } = useQuery({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!), queryFn: async () => { try { return await accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"); } catch (err) { if (err instanceof ApiError && (err.status === 403 || err.status === 401)) { return []; } throw err; } }, enabled: !!selectedCompanyId, retry: false, }); const { data: dashboard, isLoading: isDashboardLoading } = useQuery({ queryKey: queryKeys.dashboard(selectedCompanyId!), queryFn: () => dashboardApi.summary(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: issues, isLoading: isIssuesLoading } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, } = useQuery({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", status: "backlog,todo,in_progress,in_review,blocked,done", }), enabled: !!selectedCompanyId, }); const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!), queryFn: () => heartbeatsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const staleIssues = useMemo( () => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)), [issues, dismissed], ); const sortByMostRecentActivity = useCallback( (a: Issue, b: Issue) => { const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a); if (activityDiff !== 0) return activityDiff; return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt); }, [], ); const touchedIssues = useMemo( () => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), [sortByMostRecentActivity, touchedIssuesRaw], ); const agentById = useMemo(() => { const map = new Map(); for (const agent of agents ?? []) map.set(agent.id, agent.name); return map; }, [agents]); const issueById = useMemo(() => { const map = new Map(); for (const issue of issues ?? []) map.set(issue.id, issue); return map; }, [issues]); const failedRuns = useMemo( () => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)), [heartbeatRuns, dismissed], ); const allApprovals = useMemo( () => [...(approvals ?? [])].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ), [approvals], ); const actionableApprovals = useMemo( () => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)), [allApprovals], ); const filteredAllApprovals = useMemo(() => { if (allApprovalFilter === "all") return allApprovals; return allApprovals.filter((approval) => { const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); return allApprovalFilter === "actionable" ? isActionable : !isActionable; }); }, [allApprovals, allApprovalFilter]); const agentName = (id: string | null) => { if (!id) return null; return agentById.get(id) ?? null; }; const approveMutation = useMutation({ mutationFn: (id: string) => approvalsApi.approve(id), onSuccess: (_approval, id) => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); navigate(`/approvals/${id}?resolved=approved`); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to approve"); }, }); const rejectMutation = useMutation({ mutationFn: (id: string) => approvalsApi.reject(id), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reject"); }, }); const approveJoinMutation = useMutation({ mutationFn: (joinRequest: JoinRequest) => accessApi.approveJoinRequest(selectedCompanyId!, joinRequest.id), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to approve join request"); }, }); const rejectJoinMutation = useMutation({ mutationFn: (joinRequest: JoinRequest) => accessApi.rejectJoinRequest(selectedCompanyId!, joinRequest.id), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reject join request"); }, }); const [fadingOutIssues, setFadingOutIssues] = useState>(new Set()); const markReadMutation = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onMutate: (id) => { setFadingOutIssues((prev) => new Set(prev).add(id)); }, onSuccess: () => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }, onSettled: (_data, _error, id) => { setTimeout(() => { setFadingOutIssues((prev) => { const next = new Set(prev); next.delete(id); return next; }); }, 300); }, }); if (!selectedCompanyId) { return ; } const hasRunFailures = failedRuns.length > 0; const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors"); const showBudgetAlert = !!dashboard && dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthUtilizationPercent >= 80 && !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasStale = staleIssues.length > 0; const hasJoinRequests = joinRequests.length > 0; const hasTouchedIssues = touchedIssues.length > 0; const newItemCount = failedRuns.length + staleIssues.length + (showAggregateAgentError ? 1 : 0) + (showBudgetAlert ? 1 : 0); const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; const showTouchedCategory = allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched"; const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals"; const showFailedRunsCategory = allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work"; const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues; const showJoinRequestsSection = tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests; const showApprovalsSection = tab === "new" ? actionableApprovals.length > 0 : showApprovalsCategory && filteredAllApprovals.length > 0; const showFailedRunsSection = tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, showStaleSection ? "stale_work" : null, showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, showTouchedSection ? "issues_i_touched" : null, ].filter((key): key is SectionKey => key !== null); const allLoaded = !isJoinRequestsLoading && !isApprovalsLoading && !isDashboardLoading && !isIssuesLoading && !isTouchedIssuesLoading && !isRunsLoading; const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; return (
navigate(`/inbox/${value === "all" ? "all" : "new"}`)}> New {newItemCount > 0 && ( {newItemCount} )} ), }, { value: "all", label: "All" }, ]} /> {tab === "all" && (
{showApprovalsCategory && ( )}
)}
{approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} {!allLoaded && visibleSections.length === 0 && ( )} {allLoaded && visibleSections.length === 0 && ( )} {showApprovalsSection && ( <> {showSeparatorBefore("approvals") && }

{tab === "new" ? "Approvals Needing Action" : "Approvals"}

{approvalsToRender.map((approval) => ( a.id === approval.requestedByAgentId) ?? null : null } onApprove={() => approveMutation.mutate(approval.id)} onReject={() => rejectMutation.mutate(approval.id)} detailLink={`/approvals/${approval.id}`} isPending={approveMutation.isPending || rejectMutation.isPending} /> ))}
)} {showJoinRequestsSection && ( <> {showSeparatorBefore("join_requests") && }

Join Requests

{joinRequests.map((joinRequest) => (

{joinRequest.requestType === "human" ? "Human join request" : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}

requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}

{joinRequest.requestEmailSnapshot && (

email: {joinRequest.requestEmailSnapshot}

)} {joinRequest.adapterType && (

adapter: {joinRequest.adapterType}

)}
))}
)} {showFailedRunsSection && ( <> {showSeparatorBefore("failed_runs") && }

Failed Runs

{failedRuns.map((run) => ( dismiss(`run:${run.id}`)} /> ))}
)} {showAlertsSection && ( <> {showSeparatorBefore("alerts") && }

Alerts

{showAggregateAgentError && (
{dashboard!.agents.error}{" "} {dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
)} {showBudgetAlert && (
Budget at{" "} {dashboard!.costs.monthUtilizationPercent}%{" "} utilization this month
)}
)} {showStaleSection && ( <> {showSeparatorBefore("stale_work") && }

Stale Work

{staleIssues.map((issue) => (
{/* Status icon - left column on mobile; Clock icon on desktop */} {issue.title} {issue.identifier ?? issue.id.slice(0, 8)} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : null; })()} · updated {timeAgo(issue.updatedAt)}
))}
)} {showTouchedSection && ( <> {showSeparatorBefore("issues_i_touched") && }

My Recent Issues

{touchedIssues.map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( {/* Status icon - left column on mobile, inline on desktop */} {/* Right column on mobile: title + metadata stacked */} {issue.title} {(isUnread || isFading) ? ( { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); } }} className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" aria-label="Mark as read" > ) : ( )} {issue.identifier ?? issue.id.slice(0, 8)} · {issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` : `updated ${timeAgo(issue.updatedAt)}`} {/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */} {(isUnread || isFading) && ( { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); } }} className="shrink-0 self-center cursor-pointer sm:hidden" aria-label="Mark as read" > )} ); })}
)}
); }