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 { authApi } from "../api/auth"; import { ApiError } from "../api/client"; import { dashboardApi } from "../api/dashboard"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { queryKeys } from "../lib/queryKeys"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, createIssueDetailPath, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, issueTrailingColumns, } from "../components/IssueColumns"; 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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, ChevronRight, XCircle, X, RotateCcw, UserPlus, Search, ListTree, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, DEFAULT_INBOX_ISSUE_COLUMNS, buildInboxNesting, getAvailableInboxIssueColumns, getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, getLatestFailedRunsByAgent, getRecentTouchedIssues, isMineInboxTab, loadInboxIssueColumns, loadInboxNesting, normalizeInboxIssueColumns, resolveIssueWorkspaceName, resolveInboxSelectionIndex, saveInboxIssueColumns, saveInboxNesting, InboxApprovalFilter, type InboxIssueColumn, saveLastInboxTab, shouldShowInboxSection, type InboxTab, type InboxWorkItem, } from "../lib/inbox"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns"; type InboxCategoryFilter = | "everything" | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" | "alerts"; type SectionKey = | "work_items" | "alerts"; /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = | { type: "top"; index: number; item: InboxWorkItem } | { type: "child"; parentIndex: number; issue: Issue }; 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, selected = false, className, }: { joinRequest: JoinRequest; onApprove: () => void; onReject: () => void; isPending: boolean; unreadState?: NonIssueUnreadState; onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; selected?: 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 { keyboardShortcutsEnabled } = useGeneralSettings(); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const [searchQuery, setSearchQuery] = useState(""); const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); const [allApprovalFilter, setAllApprovalFilter] = useState("all"); const [visibleIssueColumns, setVisibleIssueColumns] = useState(loadInboxIssueColumns); 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: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.list(selectedCompanyId) : ["execution-workspaces", "__disabled__"], queryFn: () => executionWorkspacesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); useEffect(() => { saveLastInboxTab(tab); setSelectedIndex(-1); setSearchQuery(""); }, [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 projectById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { map.set(project.id, { name: project.name, color: project.color }); } return map; }, [projects]); const projectWorkspaceById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { for (const workspace of project.workspaces ?? []) { map.set(workspace.id, { name: workspace.name }); } } return map; }, [projects]); const defaultProjectWorkspaceIdByProjectId = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { const defaultWorkspaceId = project.executionWorkspacePolicy?.defaultProjectWorkspaceId ?? project.primaryWorkspace?.id ?? null; if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId); } return map; }, [projects]); const executionWorkspaceById = useMemo(() => { const map = new Map(); for (const workspace of executionWorkspaces) { map.set(workspace.id, { name: workspace.name, mode: workspace.mode, projectWorkspaceId: workspace.projectWorkspaceId ?? null, }); } return map; }, [executionWorkspaces]); const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), [isolatedWorkspacesEnabled], ); const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]); const visibleTrailingIssueColumns = useMemo( () => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)), [availableIssueColumnSet, visibleIssueColumnSet], ); const currentUserId = session?.user.id ?? session?.session.userId ?? null; 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 filteredWorkItems = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return workItemsToRender; return workItemsToRender.filter((item) => { if (item.kind === "issue") { const issue = item.issue; if (issue.title.toLowerCase().includes(q)) return true; if (issue.identifier?.toLowerCase().includes(q)) return true; if (issue.description?.toLowerCase().includes(q)) return true; if (isolatedWorkspacesEnabled) { const workspaceName = resolveIssueWorkspaceName(issue, { executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, }); if (workspaceName?.toLowerCase().includes(q)) return true; } return false; } if (item.kind === "approval") { const a = item.approval; const label = approvalLabel(a.type, a.payload as Record | null); if (label.toLowerCase().includes(q)) return true; if (a.type.toLowerCase().includes(q)) return true; return false; } if (item.kind === "failed_run") { const run = item.run; const name = agentById.get(run.agentId); if (name?.toLowerCase().includes(q)) return true; const msg = runFailureMessage(run); if (msg.toLowerCase().includes(q)) return true; const issueId = readIssueIdFromRun(run); if (issueId) { const issue = issueById.get(issueId); if (issue?.title.toLowerCase().includes(q)) return true; if (issue?.identifier?.toLowerCase().includes(q)) return true; } return false; } if (item.kind === "join_request") { const jr = item.joinRequest; if (jr.agentName?.toLowerCase().includes(q)) return true; if (jr.capabilities?.toLowerCase().includes(q)) return true; return false; } return false; }); }, [ workItemsToRender, searchQuery, agentById, defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, issueById, isolatedWorkspacesEnabled, projectWorkspaceById, ]); // --- Parent-child nesting for inbox issues --- const [nestingEnabled, setNestingEnabled] = useState(() => loadInboxNesting()); const toggleNesting = useCallback(() => { setNestingEnabled((prev) => { const next = !prev; saveInboxNesting(next); return next; }); }, []); const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set()); const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo( () => nestingEnabled ? buildInboxNesting(filteredWorkItems) : { displayItems: filteredWorkItems, childrenByIssueId: new Map() }, [filteredWorkItems, nestingEnabled], ); const toggleInboxParentCollapse = useCallback((parentId: string) => { setCollapsedInboxParents((prev) => { const next = new Set(prev); if (next.has(parentId)) next.delete(parentId); else next.add(parentId); return next; }); }, []); // Build flat navigation list including expanded children for keyboard traversal const flatNavItems = useMemo((): NavEntry[] => { const entries: NavEntry[] = []; for (let i = 0; i < nestedWorkItems.length; i++) { const item = nestedWorkItems[i]; entries.push({ type: "top", index: i, item }); if (item.kind === "issue") { const children = childrenByIssueId.get(item.issue.id); const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id); if (isExpanded) { for (const child of children) { entries.push({ type: "child", parentIndex: i, issue: child }); } } } } return entries; }, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]); const agentName = (id: string | null) => { if (!id) return null; return agentById.get(id) ?? null; }; const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); saveInboxIssueColumns(normalized); }, []); const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => { if (enabled) { setIssueColumns([...visibleIssueColumns, column]); return; } setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); }, [setIssueColumns, visibleIssueColumns]); 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(result.message ?? "Retry was skipped."); } 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 [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false); 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: async (id) => { setActionError(null); setArchivingIssueIds((prev) => new Set(prev).add(id)); // Cancel in-flight refetches so they don't overwrite our optimistic update const queryKeys_ = [ queryKeys.issues.listMineByMe(selectedCompanyId!), queryKeys.issues.listTouchedByMe(selectedCompanyId!), queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!), ]; await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk }))); // Snapshot previous data for rollback const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const); // Optimistically remove the issue from all inbox query caches for (const qk of queryKeys_) { queryClient.setQueryData(qk, (old: unknown) => { if (!Array.isArray(old)) return old; return old.filter((issue: { id: string }) => issue.id !== id); }); } return { previousData }; }, onError: (err, id, context) => { setActionError(err instanceof Error ? err.message : "Failed to archive issue"); setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); // Restore previous query data on failure if (context?.previousData) { for (const [qk, data] of context.previousData) { queryClient.setQueryData(qk, data); } } }, onSettled: (_data, error, id) => { // Clean up archiving state and refetch to sync with server setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); invalidateInboxIssueQueries(); }, }); 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, flatNavItems.length)); }, [flatNavItems.length]); // Use refs for keyboard handler to avoid stale closures const kbStateRef = useRef({ workItems: nestedWorkItems, flatNavItems, selectedIndex, canArchive: canArchiveFromTab, archivingIssueIds, archivingNonIssueIds, fadingOutIssues, readItems, }); kbStateRef.current = { workItems: nestedWorkItems, flatNavItems, 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(() => { if (!keyboardShortcutsEnabled) return; 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) || isKeyboardShortcutTextInputTarget(target) || hasBlockingShortcutDialog(document) || 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 navItems = st.flatNavItems; const navCount = navItems.length; if (navCount === 0) return; /** Resolve the nav entry at selectedIndex to an issue (for child entries) or work item. */ const resolveNavEntry = (idx: number): { issue?: Issue; item?: InboxWorkItem } => { const entry = navItems[idx]; if (!entry) return {}; if (entry.type === "child") return { issue: entry.issue }; return { item: entry.item }; }; switch (e.key) { case "j": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next")); break; } case "k": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous")); break; } case "a": case "y": { if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); } else if (item) { 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 >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { act.markUnreadIssue(issue.id); } else if (item) { if (item.kind === "issue") act.markUnreadIssue(item.issue.id); else act.markNonIssueUnread(getWorkItemKey(item)); } break; } case "r": { if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id); } else if (item) { 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 >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(st.selectedIndex); if (issue) { const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(issueLinkState); rememberIssueDetailLocationState(pathId, detailState); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { const pathId = item.issue.identifier ?? item.issue.id; const detailState = armIssueDetailInboxQuickArchive(issueLinkState); rememberIssueDetailLocationState(pathId, detailState); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } 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, keyboardShortcutsEnabled]); // 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 = nestedWorkItems.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 (
{/* Search — full-width row on mobile, inline on desktop */}
setSearchQuery(e.target.value)} className="h-8 w-full pl-8 text-xs" />
navigate(`/inbox/${value}`)}>
setSearchQuery(e.target.value)} className="h-8 w-[220px] pl-8 text-xs" />
setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which inbox columns stay visible" /> {canMarkAllRead && ( <> Mark all as read? This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read. )}
{tab === "all" && (
{showApprovalsCategory && ( )}
)} {approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} {!allLoaded && visibleSections.length === 0 && ( )} {allLoaded && visibleSections.length === 0 && ( )} {showWorkItemsSection && ( <> {showSeparatorBefore("work_items") && }
{(() => { // Pre-compute flat nav index for each top-level item and child issue let flatIdx = 0; const topFlatIndex = new Map(); const childFlatIndex = new Map(); for (let ti = 0; ti < nestedWorkItems.length; ti++) { topFlatIndex.set(ti, flatIdx); flatIdx++; const topItem = nestedWorkItems[ti]; if (topItem.kind === "issue") { const children = childrenByIssueId.get(topItem.issue.id); const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id); if (isExp) { for (const c of children) { childFlatIndex.set(c.id, flatIdx); flatIdx++; } } } } return nestedWorkItems.flatMap((item, index) => { const navIdx = topFlatIndex.get(index) ?? index; const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
setSelectedIndex(navIdx)} > {child}
); const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; const showTodayDivider = index > 0 && item.timestamp > 0 && item.timestamp < todayCutoff && nestedWorkItems[index - 1].timestamp >= todayCutoff; const elements: ReactNode[] = []; if (showTodayDivider) { elements.push(
Earlier
, ); } const isSelected = selectedIndex === navIdx; 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 childIssues = childrenByIssueId.get(issue.id) ?? []; const hasChildren = childIssues.length > 0; const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => { const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id); const isFading = fadingOutIssues.has(iss.id); const isArch = archivingIssueIds.has(iss.id); const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null; return ( {nestingEnabled ? ( depth === 0 && hasChildren ? ( ) : ( ) ) : null} {depth > 0 ? ( ) : null} } titleSuffix={hasChildren && !isExpanded && depth === 0 ? ( ({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""}) ) : undefined} mobileMeta={issueActivityText(iss).toLowerCase()} mobileLeading={ depth === 0 && hasChildren ? ( ) : undefined } unreadState={ isUnread ? "visible" : isFading ? "fading" : "hidden" } onMarkRead={() => markReadMutation.mutate(iss.id)} onArchive={ canArchiveFromTab ? () => archiveIssueMutation.mutate(iss.id) : undefined } archiveDisabled={isArch || archiveIssueMutation.isPending} desktopTrailing={ visibleTrailingIssueColumns.length > 0 ? ( ) : undefined } /> ); }; // Render parent issue const parentRow = renderInboxIssue(issue, 0, isSelected); elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( archiveIssueMutation.mutate(issue.id)} > {parentRow} ) : parentRow)); // Render children if expanded if (isExpanded) { for (const child of childIssues) { const cNavIdx = childFlatIndex.get(child.id) ?? -1; const isChildSelected = selectedIndex === cNavIdx; const childRow = renderInboxIssue(child, 1, isChildSelected); const isChildArchiving = archivingIssueIds.has(child.id); elements.push(
setSelectedIndex(cNavIdx)} > {canArchiveFromTab ? ( archiveIssueMutation.mutate(child.id)} > {childRow} ) : childRow}
, ); } } 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
)}
)}
); }