import { 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 { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { ApprovalCard } from "../components/ApprovalCard"; import { IssueRow } from "../components/IssueRow"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusIcon } from "../components/StatusIcon"; 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, 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"; import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, getRecentTouchedIssues, type InboxTab, saveLastInboxTab, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; type InboxCategoryFilter = | "everything" | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts"; type InboxApprovalFilter = "all" | "actionable" | "resolved"; type SectionKey = | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts"; const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", assignment: "Assignment", on_demand: "Manual", automation: "Automation", }; 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 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, issueLinkState, onDismiss, }: { run: HeartbeatRun; issueById: Map; agentName: string | null; issueLinkState: unknown; 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 } = useDismissedInboxItems(); const pathSegment = location.pathname.split("/").pop() ?? "recent"; const tab: InboxTab = pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent"; const issueLinkState = useMemo( () => createIssueDetailLocationState( "Inbox", `${location.pathname}${location.search}${location.hash}`, ), [location.pathname, location.search, location.hash], ); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); useEffect(() => { saveLastInboxTab(tab); }, [tab]); 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 touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const unreadTouchedIssues = useMemo( () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], ); 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 liveIssueIds = useMemo(() => { const ids = new Set(); for (const run of heartbeatRuns ?? []) { if (run.status !== "running" && run.status !== "queued") continue; const issueId = readIssueIdFromRun(run); if (issueId) ids.add(issueId); } return ids; }, [heartbeatRuns]); 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 invalidateInboxIssueQueries = () => { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); }; const markReadMutation = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onMutate: (id) => { setFadingOutIssues((prev) => new Set(prev).add(id)); }, onSuccess: () => { invalidateInboxIssueQueries(); }, onSettled: (_data, _error, id) => { setTimeout(() => { setFadingOutIssues((prev) => { const next = new Set(prev); next.delete(id); return next; }); }, 300); }, }); const markAllReadMutation = useMutation({ mutationFn: async (issueIds: string[]) => { await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId))); }, onMutate: (issueIds) => { setFadingOutIssues((prev) => { const next = new Set(prev); for (const issueId of issueIds) next.add(issueId); return next; }); }, onSuccess: () => { invalidateInboxIssueQueries(); }, onSettled: (_data, _error, issueIds) => { setTimeout(() => { setFadingOutIssues((prev) => { const next = new Set(prev); for (const issueId of issueIds) next.delete(issueId); 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 hasJoinRequests = joinRequests.length > 0; const hasTouchedIssues = touchedIssues.length > 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 approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals; const showTouchedSection = tab === "all" ? showTouchedCategory && hasTouchedIssues : tab === "unread" ? unreadTouchedIssues.length > 0 : hasTouchedIssues; const showJoinRequestsSection = tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; const showApprovalsSection = tab === "all" ? showApprovalsCategory && filteredAllApprovals.length > 0 : actionableApprovals.length > 0; const showFailedRunsSection = tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures; const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : 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; const unreadIssueIds = unreadTouchedIssues .filter((issue) => !fadingOutIssues.has(issue.id)) .map((issue) => issue.id); const canMarkAllRead = unreadIssueIds.length > 0; return (
navigate(`/inbox/${value}`)}> {canMarkAllRead && ( )}
{tab === "all" && (
{showApprovalsCategory && ( )}
)}
{approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

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

{tab === "unread" ? "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
)}
)} {showTouchedSection && ( <> {showSeparatorBefore("issues_i_touched") && }
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( {issue.identifier ?? issue.id.slice(0, 8)} {liveIssueIds.has(issue.id) && ( Live )} )} mobileMeta={ issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` : `updated ${timeAgo(issue.updatedAt)}` } unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} onMarkRead={() => markReadMutation.mutate(issue.id)} trailingMeta={ issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` : `updated ${timeAgo(issue.updatedAt)}` } /> ); })}
)}
); }