import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; 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, createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; import { SwipeToArchive } from "../components/SwipeToArchive"; import { StatusIcon } from "../components/StatusIcon"; import { cn } from "../lib/utils"; import { StatusBadge } from "../components/StatusBadge"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; 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, XCircle, X, RotateCcw, UserPlus, } from "lucide-react"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, getLatestFailedRunsByAgent, getRecentTouchedIssues, isMineInboxTab, resolveInboxSelectionIndex, InboxApprovalFilter, saveLastInboxTab, shouldShowInboxSection, type InboxTab, type InboxWorkItem, } from "../lib/inbox"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; type InboxCategoryFilter = | "everything" | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts"; type SectionKey = | "work_items" | "alerts"; 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 approvalStatusLabel(status: Approval["status"]): string { return status.replaceAll("_", " "); } 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; } type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; export function FailedRunInboxRow({ run, issueById, agentName: linkedAgentName, issueLinkState, onDismiss, onRetry, isRetrying, unreadState = null, onMarkRead, onArchive, archiveDisabled, selected = false, className, }: { run: HeartbeatRun; issueById: Map; agentName: string | null; issueLinkState: unknown; onDismiss: () => void; onRetry: () => void; isRetrying: boolean; unreadState?: NonIssueUnreadState; onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; selected?: boolean; className?: string; }) { const issueId = readIssueIdFromRun(run); const issue = issueId ? issueById.get(issueId) ?? null : null; const displayError = runFailureMessage(run); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; return (
{showUnreadSlot ? ( {showUnreadDot ? ( ) : onArchive ? ( ) : ( ) : null} {!showUnreadSlot &&
{!showUnreadSlot && ( )}
); } function ApprovalInboxRow({ approval, requesterName, onApprove, onReject, isPending, unreadState = null, onMarkRead, onArchive, archiveDisabled, selected = false, className, }: { approval: Approval; requesterName: string | null; onApprove: () => void; onReject: () => void; isPending: boolean; unreadState?: NonIssueUnreadState; onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; selected?: boolean; className?: string; }) { const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const label = approvalLabel(approval.type, approval.payload as Record | null); const showResolutionButtons = approval.type !== "budget_override_required" && ACTIONABLE_APPROVAL_STATUSES.has(approval.status); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; return (
{showUnreadSlot ? ( {showUnreadDot ? ( ) : onArchive ? ( ) : ( ) : null} {!showUnreadSlot &&
{showResolutionButtons ? (
) : null}
); } function JoinRequestInboxRow({ joinRequest, onApprove, onReject, isPending, unreadState = null, onMarkRead, onArchive, archiveDisabled, className, }: { joinRequest: JoinRequest; onApprove: () => void; onReject: () => void; isPending: boolean; unreadState?: NonIssueUnreadState; onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; className?: string; }) { const label = joinRequest.requestType === "human" ? "Human join request" : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`; const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; return (
{showUnreadSlot ? ( {showUnreadDot ? ( ) : onArchive ? ( ) : ( ) : null}
{!showUnreadSlot &&
); } 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 { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const pathSegment = location.pathname.split("/").pop() ?? "mine"; const tab: InboxTab = pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread" ? pathSegment : "mine"; const canArchiveFromTab = isMineInboxTab(tab); const issueLinkState = useMemo( () => createIssueDetailLocationState( "Inbox", `${location.pathname}${location.search}${location.hash}`, "inbox", ), [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); setSelectedIndex(-1); }, [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: mineIssuesRaw = [], isLoading: isMineIssuesLoading, } = useQuery({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, }), enabled: !!selectedCompanyId, }); const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, } = useQuery({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, }), enabled: !!selectedCompanyId, }); const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!), queryFn: () => heartbeatsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const unreadTouchedIssues = useMemo( () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], ); const issuesToRender = useMemo( () => { if (tab === "mine") return mineIssues; if (tab === "unread") return unreadTouchedIssues; return touchedIssues; }, [tab, mineIssues, touchedIssues, unreadTouchedIssues], ); 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 approvalsToRender = useMemo(() => { let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter); if (tab === "mine") { filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`)); } return filtered; }, [approvals, tab, allApprovalFilter, dismissed]); 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 failedRunsForTab = useMemo(() => { if (tab === "all" && !showFailedRunsCategory) return []; return failedRuns; }, [failedRuns, tab, showFailedRunsCategory]); const joinRequestsForTab = useMemo(() => { if (tab === "all" && !showJoinRequestsCategory) return []; if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`)); return joinRequests; }, [joinRequests, tab, showJoinRequestsCategory, dismissed]); const workItemsToRender = useMemo( () => getInboxWorkItems({ issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, failedRuns: failedRunsForTab, joinRequests: joinRequestsForTab, }), [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab], ); 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 [retryingRunIds, setRetryingRunIds] = useState>(new Set()); const retryRunMutation = useMutation({ mutationFn: async (run: HeartbeatRun) => { 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 { newRun: result, originalRun: run }; }, onMutate: (run) => { setRetryingRunIds((prev) => new Set(prev).add(run.id)); }, onSuccess: ({ newRun, originalRun }) => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) }); navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`); }, onSettled: (_data, _error, run) => { if (!run) return; setRetryingRunIds((prev) => { const next = new Set(prev); next.delete(run.id); return next; }); }, }); const [fadingOutIssues, setFadingOutIssues] = useState>(new Set()); const [archivingIssueIds, setArchivingIssueIds] = useState>(new Set()); const [fadingNonIssueItems, setFadingNonIssueItems] = useState>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState>(new Set()); const [selectedIndex, setSelectedIndex] = useState(-1); const listRef = useRef(null); const invalidateInboxIssueQueries = () => { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); }; const archiveIssueMutation = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), onMutate: (id) => { setActionError(null); setArchivingIssueIds((prev) => new Set(prev).add(id)); }, onSuccess: () => { invalidateInboxIssueQueries(); }, onError: (err, id) => { setActionError(err instanceof Error ? err.message : "Failed to archive issue"); setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); }, onSettled: (_data, error, id) => { if (error) return; window.setTimeout(() => { setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); }, 500); }, }); 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); }, }); const markUnreadMutation = useMutation({ mutationFn: (id: string) => issuesApi.markUnread(id), onSuccess: () => { invalidateInboxIssueQueries(); }, }); const handleMarkNonIssueRead = useCallback((key: string) => { setFadingNonIssueItems((prev) => new Set(prev).add(key)); markItemRead(key); setTimeout(() => { setFadingNonIssueItems((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, 300); }, [markItemRead]); const handleArchiveNonIssue = useCallback((key: string) => { setArchivingNonIssueIds((prev) => new Set(prev).add(key)); setTimeout(() => { dismiss(key); setArchivingNonIssueIds((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, 200); }, [dismiss]); const nonIssueUnreadState = (key: string): NonIssueUnreadState => { if (!canArchiveFromTab) return null; const isRead = readItems.has(key); const isFading = fadingNonIssueItems.has(key); if (isFading) return "fading"; if (!isRead) return "visible"; return "hidden"; }; const getWorkItemKey = useCallback((item: InboxWorkItem): string => { if (item.kind === "issue") return `issue:${item.issue.id}`; if (item.kind === "approval") return `approval:${item.approval.id}`; if (item.kind === "failed_run") return `run:${item.run.id}`; return `join:${item.joinRequest.id}`; }, []); // Keep selection valid when the list shape changes, but do not auto-select on initial load. useEffect(() => { setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length)); }, [workItemsToRender.length]); // Use refs for keyboard handler to avoid stale closures const kbStateRef = useRef({ workItems: workItemsToRender, selectedIndex, canArchive: canArchiveFromTab, archivingIssueIds, archivingNonIssueIds, fadingOutIssues, readItems, }); kbStateRef.current = { workItems: workItemsToRender, selectedIndex, canArchive: canArchiveFromTab, archivingIssueIds, archivingNonIssueIds, fadingOutIssues, readItems, }; const kbActionsRef = useRef({ archiveIssue: (id: string) => archiveIssueMutation.mutate(id), archiveNonIssue: handleArchiveNonIssue, markRead: (id: string) => markReadMutation.mutate(id), markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, navigate, }); kbActionsRef.current = { archiveIssue: (id: string) => archiveIssueMutation.mutate(id), archiveNonIssue: handleArchiveNonIssue, markRead: (id: string) => markReadMutation.mutate(id), markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, navigate, }; // Keyboard shortcuts (mail-client style) — single stable listener using refs useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; // Don't capture when typing in inputs/textareas or with modifier keys const target = e.target; if ( !(target instanceof HTMLElement) || target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") || target.isContentEditable || document.querySelector("[role='dialog'], [aria-modal='true']") || e.metaKey || e.ctrlKey || e.altKey ) { return; } const st = kbStateRef.current; const act = kbActionsRef.current; // Keyboard shortcuts are only active on the "mine" tab if (!st.canArchive) return; const itemCount = st.workItems.length; if (itemCount === 0) return; switch (e.key) { case "j": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next")); break; } case "k": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous")); break; } case "a": case "y": { if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; e.preventDefault(); const item = st.workItems[st.selectedIndex]; if (item.kind === "issue") { if (!st.archivingIssueIds.has(item.issue.id)) { act.archiveIssue(item.issue.id); } } else { const key = getWorkItemKey(item); if (!st.archivingNonIssueIds.has(key)) { act.archiveNonIssue(key); } } break; } case "U": { if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; e.preventDefault(); const item = st.workItems[st.selectedIndex]; if (item.kind === "issue") { act.markUnreadIssue(item.issue.id); } else { act.markNonIssueUnread(getWorkItemKey(item)); } break; } case "r": { if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; e.preventDefault(); const item = st.workItems[st.selectedIndex]; if (item.kind === "issue") { if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) { act.markRead(item.issue.id); } } else { const key = getWorkItemKey(item); if (!st.readItems.has(key)) { act.markNonIssueRead(key); } } break; } case "Enter": { if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; e.preventDefault(); const item = st.workItems[st.selectedIndex]; if (item.kind === "issue") { const pathId = item.issue.identifier ?? item.issue.id; act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState }); } else if (item.kind === "approval") { act.navigate(`/approvals/${item.approval.id}`); } else if (item.kind === "failed_run") { act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`); } break; } default: return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [getWorkItemKey, issueLinkState]); // Scroll selected item into view useEffect(() => { if (selectedIndex < 0 || !listRef.current) return; const rows = listRef.current.querySelectorAll("[data-inbox-item]"); const row = rows[selectedIndex]; if (row) row.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); 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 showWorkItemsSection = workItemsToRender.length > 0; const showAlertsSection = shouldShowInboxSection({ tab, hasItems: hasAlerts, showOnMine: hasAlerts, showOnRecent: hasAlerts, showOnUnread: hasAlerts, showOnAll: showAlertsCategory && hasAlerts, }); const visibleSections = [ showAlertsSection ? "alerts" : null, showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); const allLoaded = !isJoinRequestsLoading && !isApprovalsLoading && !isDashboardLoading && !isIssuesLoading && !isMineIssuesLoading && !isTouchedIssuesLoading && !isRunsLoading; const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; const markAllReadIssues = (tab === "mine" ? mineIssues : unreadTouchedIssues) .filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id)); const unreadIssueIds = markAllReadIssues .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 && ( )} {showWorkItemsSection && ( <> {showSeparatorBefore("work_items") && }
{workItemsToRender.flatMap((item, index) => { const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
setSelectedIndex(index)} > {child}
); const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; const showTodayDivider = index > 0 && item.timestamp > 0 && item.timestamp < todayCutoff && workItemsToRender[index - 1].timestamp >= todayCutoff; const elements: ReactNode[] = []; if (showTodayDivider) { elements.push(
Today
, ); } const isSelected = selectedIndex === index; if (item.kind === "approval") { const approvalKey = `approval:${item.approval.id}`; const isArchiving = archivingNonIssueIds.has(approvalKey); const row = ( approveMutation.mutate(item.approval.id)} onReject={() => rejectMutation.mutate(item.approval.id)} isPending={approveMutation.isPending || rejectMutation.isPending} unreadState={nonIssueUnreadState(approvalKey)} onMarkRead={() => handleMarkNonIssueRead(approvalKey)} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined} archiveDisabled={isArchiving} className={ isArchiving ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" : "transition-all duration-200 ease-out" } /> ); elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? ( handleArchiveNonIssue(approvalKey)} > {row} ) : row)); return elements; } if (item.kind === "failed_run") { const runKey = `run:${item.run.id}`; const isArchiving = archivingNonIssueIds.has(runKey); const row = ( dismiss(runKey)} onRetry={() => retryRunMutation.mutate(item.run)} isRetrying={retryingRunIds.has(item.run.id)} unreadState={nonIssueUnreadState(runKey)} onMarkRead={() => handleMarkNonIssueRead(runKey)} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined} archiveDisabled={isArchiving} className={ isArchiving ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" : "transition-all duration-200 ease-out" } /> ); elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? ( handleArchiveNonIssue(runKey)} > {row} ) : row)); return elements; } if (item.kind === "join_request") { const joinKey = `join:${item.joinRequest.id}`; const isArchiving = archivingNonIssueIds.has(joinKey); const row = ( approveJoinMutation.mutate(item.joinRequest)} onReject={() => rejectJoinMutation.mutate(item.joinRequest)} isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} unreadState={nonIssueUnreadState(joinKey)} onMarkRead={() => handleMarkNonIssueRead(joinKey)} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined} archiveDisabled={isArchiving} className={ isArchiving ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" : "transition-all duration-200 ease-out" } /> ); elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? ( handleArchiveNonIssue(joinKey)} > {row} ) : row)); return elements; } const issue = item.issue; const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id); const row = ( {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)} onArchive={ canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined } archiveDisabled={isArchiving || archiveIssueMutation.isPending} trailingMeta={ issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` : `updated ${timeAgo(issue.updatedAt)}` } /> ); elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( archiveIssueMutation.mutate(issue.id)} > {row} ) : row)); return elements; })}
)} {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
)}
)}
); }