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 { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER, isHeartbeatRunVisibleInMine } from "@paperclipai/shared"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; 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 { BLOCKED_GROUP_OPTIONS, BLOCKED_SORT_OPTIONS, type BlockedInboxGroupBy, type BlockedInboxSort, } from "../lib/blockedInbox"; import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { useDialogActions } from "../context/DialogContext"; import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState, normalizeIssueFilterState, type IssueFilterState, } from "../lib/issue-filters"; import { collectLiveIssueIds, collectSubtreeLiveCounts } from "../lib/liveIssueIds"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, createIssueDetailPath, rememberIssueDetailLocationState, withIssueDetailHeaderSeed, } from "../lib/issueDetailBreadcrumb"; import { prefetchIssueDetailForNavigation } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget, resolveInboxUndoArchiveKeyAction, shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; import { resolveInboxIssueBlockerAttention, resolveIssueLiveDescendantCount, } from "../lib/inbox-live-descendants"; import { beginLocalInboxArchive, boundLocalInboxArchive, cancelInboxIssueQueries, clearLocalInboxArchive, confirmLocalInboxArchive, invalidateInboxIssueQueries, getIssuePresenceInActiveInboxCaches, removeIssueFromInboxCaches, restoreIssueToInboxCaches, snapshotInboxIssueCaches, type InboxIssueCacheSnapshot, useLocalInboxArchiveIssueIds, } from "../lib/inboxArchiveCache"; import { EmptyState } from "../components/EmptyState"; import { CollectionToolbar, type CollectionToolbarProps } from "../components/CollectionToolbar"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { PageSkeleton } from "../components/PageSkeleton"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, issueActivityTimestamp, issueTrailingColumns, } from "../components/IssueColumns"; import { IssueFiltersPopover } from "../components/IssueFiltersPopover"; import { InboxArchiveButton, IssueRow } from "../components/IssueRow"; import { BlockedInboxView } from "../components/BlockedInboxView"; import { SwipeToArchive } from "../components/SwipeToArchive"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { Inbox as LegacyInbox } from "./LegacyInbox"; 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Inbox as InboxIcon, AlertTriangle, Check, ChevronRight, ArrowUpDown, Layers, Plus, 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, buildGroupedInboxSections, buildInboxIssueGroupCreateDefaults, buildInboxKeyboardNavEntries, getAvailableInboxIssueColumns, getInboxWorkItemKey, getApprovalsForTab, getArchivedInboxSearchIssues, getInboxKeyboardSelectionIndex, getInboxWorkItems, getInboxSearchSupplementIssues, getLatestFailedRunsByAgent, matchesInboxIssueSearch, getRecentTouchedIssues, isInboxEntityDismissed, isMineInboxTab, loadCollapsedInboxGroupKeys, loadCollapsedInboxParentIds, loadInboxNesting, loadInboxWorkItemGroupBy, normalizeInboxIssueColumns, resolveInboxNestingEnabled, shouldResetInboxWorkspaceGrouping, resolveIssueWorkspaceName, resolveInboxSelectionIndex, saveCollapsedInboxGroupKeys, saveCollapsedInboxParentIds, saveInboxNesting, saveInboxWorkItemGroupBy, type InboxWorkspaceGroupingOptions, type InboxApprovalFilter, type InboxCategoryFilter, type InboxFilterPreferences, type InboxIssueColumn, type InboxKeyboardNavEntry, saveLastInboxTab, shouldShowCompanyAlerts, shouldShowInboxSection, type InboxGroupedSection, type InboxTab, type InboxWorkItem, type InboxWorkItemGroupBy, INBOX_FILTER_PREFERENCES_KEY_PREFIX, INBOX_ISSUE_COLUMNS_KEY, } from "../lib/inbox"; import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge"; import { useInboxSortAttention } from "../hooks/useInboxSortAttention"; import { captureInboxOrderPin, reconcileInboxOrderPin, type InboxOrderPin, } from "../lib/inboxOrderPin"; import { loadTaskCollectionPreferences, saveTaskCollectionPreferences, type TaskCollectionPreferenceLocation, } from "../lib/task-collection-preferences"; import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup, } from "../lib/task-date-groups"; const INBOX_HEARTBEAT_RUN_LIMIT = 200; const INBOX_ISSUE_LIST_LIMIT = 500; const INBOX_HOT_PATH_STALE_MS = 30_000; const INBOX_COLLECTION_KEY = "inbox"; type StreamlinedInboxViewState = InboxFilterPreferences & { showDateGroupSeparators: boolean; }; const DEFAULT_INBOX_FILTER_PREFERENCES: StreamlinedInboxViewState = { allCategoryFilter: "everything", allApprovalFilter: "all", issueFilters: { ...defaultIssueFilterState }, showDateGroupSeparators: true, }; function normalizeInboxCollectionViewState(value: unknown): StreamlinedInboxViewState { const candidate = value && typeof value === "object" ? value as Partial : {}; const category = candidate.allCategoryFilter; const approval = candidate.allApprovalFilter; return { allCategoryFilter: category === "issues_i_touched" || category === "join_requests" || category === "approvals" || category === "failed_runs" || category === "alerts" ? category : "everything", allApprovalFilter: approval === "actionable" || approval === "resolved" ? approval : "all", issueFilters: { ...normalizeIssueFilterState(candidate.issueFilters), // The filter menu no longer offers Workspace. Drop a saved selection so // it cannot keep hiding tasks. A task-link workspace query is applied // by the task list after this load. workspaces: [], }, showDateGroupSeparators: candidate.showDateGroupSeparators !== false, }; } function inboxCollectionPreferenceLocation( companyId: string | null | undefined, ): TaskCollectionPreferenceLocation { return { companyId: companyId ?? "__unscoped__", collectionKey: INBOX_COLLECTION_KEY, legacyViewStorageKey: companyId ? `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}` : undefined, legacyColumnsStorageKey: INBOX_ISSUE_COLUMNS_KEY, }; } function loadInboxCollectionPreferences(companyId: string | null | undefined) { return loadTaskCollectionPreferences({ ...inboxCollectionPreferenceLocation(companyId), defaultViewState: DEFAULT_INBOX_FILTER_PREFERENCES, defaultColumns: DEFAULT_INBOX_ISSUE_COLUMNS, normalizeViewState: normalizeInboxCollectionViewState, normalizeColumns: (value) => Array.isArray(value) ? normalizeInboxIssueColumns(value) : DEFAULT_INBOX_ISSUE_COLUMNS, }); } export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns"; export { IssueGroupHeader as InboxGroupHeader } from "../components/IssueGroupHeader"; type SectionKey = | "work_items" | "alerts"; /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; // Stable identity for a nav row, resilient to the numeric index shifting when // the inbox reshapes (archive/poll). Used to re-anchor both the keyboard // selection and the hovered row across list refreshes. const navEntryKey = (entry: NavEntry | undefined): string | null => !entry ? null : entry.type === "top" ? `top:${entry.itemKey}` : entry.type === "child" ? `child:${entry.issueId}` : `group:${entry.groupKey}`; type CreatorOption = { id: string; label: string; kind: "agent" | "user"; searchText?: string; }; 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; } function nonEmptyLabel(value: string | null | undefined): string | null { const trimmed = value?.trim(); return trimmed ? trimmed : null; } export function formatJoinRequestInboxLabel( joinRequest: Pick< JoinRequest, "requestType" | "agentName" | "requestEmailSnapshot" | "requestingUserId" > & { requesterUser?: { name: string | null; email: string | null; } | null; }, ) { if (joinRequest.requestType !== "human") { return `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`; } const requesterName = nonEmptyLabel(joinRequest.requesterUser?.name); const requesterEmail = nonEmptyLabel(joinRequest.requesterUser?.email) ?? nonEmptyLabel(joinRequest.requestEmailSnapshot); const requesterId = nonEmptyLabel(joinRequest.requestingUserId); if (requesterName && requesterEmail) return `${requesterName} (${requesterEmail})`; if (requesterEmail) return requesterEmail; if (requesterName) return requesterName; if (requesterId) return requesterId; return "Human join request"; } type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; // Rows outside SwipeToArchive (non-archivable tabs/sections) still need the // hover-follows-selection band that SwipeToArchive's surface normally paints. function InboxRowSurface({ selected, children }: { selected: boolean; children: ReactNode }) { return
{children}
; } 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 ? ( ) : ( ) : 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 ? ( ) : ( ) : 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 = formatJoinRequestInboxLabel(joinRequest); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; return (
{showUnreadSlot ? ( {showUnreadDot ? ( ) : ( ) : null}
{!showUnreadSlot &&
{onArchive ? ( ) : null}
); } function InboxCollectionToolbar({ streamlined, context, search, controls, feedback, ariaLabel, }: CollectionToolbarProps & { streamlined: boolean }) { if (streamlined) { return ( ); } return (
{search ?
{search}
: null}
{context}
{search ?
{search}
: null} {controls}
{feedback}
); } /** * PAP-670: the inbox stopped being its own page and became the "My work" half * of Tasks. `Tasks` hosts this component and drives it through these props, so * every inbox behaviour (unread state, archive, date groups, the mixed * approval / failed-run / join-request rows) survives the merge by construction * rather than being reimplemented on the task list. * * With no props it is still the standalone `/inbox/*` page, which the legacy * (non-streamlined) shell continues to use. */ export interface InboxSurfaceProps { /** Which view to render. Falls back to the last path segment when absent. */ tab?: InboxTab; /** Replaces the inbox tab bar in the toolbar's context slot. */ toolbarContext?: ReactNode; /** Breadcrumb and back-link label; "Inbox" when standalone, "Tasks" when hosted. */ surfaceLabel?: string; } export function Inbox(props: InboxSurfaceProps = {}) { const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); return streamlinedUiEnabled ? : ; } function StreamlinedInbox({ tab: tabOverride, toolbarContext, surfaceLabel = "Inbox", }: InboxSurfaceProps) { const streamlinedUiEnabled = true; const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { openNewIssue } = useDialogActions(); const { isMobile } = useSidebar(); const navigate = useNavigate(); const { pushToast } = useToastActions(); const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const experimentalSettingsLoaded = experimentalSettings !== undefined; const [searchQuery, setSearchQuery] = useState(""); const normalizedSearchQuery = searchQuery.trim(); const [filterPreferences, setFilterPreferences] = useState( () => loadInboxCollectionPreferences(selectedCompanyId).viewState, ); const [groupBy, setGroupBy] = useState(() => loadInboxWorkItemGroupBy()); const [blockedGroupBy, setBlockedGroupBy] = useState("none"); const [blockedSortBy, setBlockedSortBy] = useState("most_recent"); const [visibleIssueColumns, setVisibleIssueColumns] = useState( () => loadInboxCollectionPreferences(selectedCompanyId).columns, ); const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts(); const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId); const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences; const pathSegment = location.pathname.split("/").pop() ?? "mine"; const pathTab: InboxTab = pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread" || pathSegment === "blocked" ? pathSegment : "mine"; const tab: InboxTab = tabOverride ?? pathTab; const canArchiveFromTab = isMineInboxTab(tab); const issueLinkState = useMemo( () => createIssueDetailLocationState( surfaceLabel, `${location.pathname}${location.search}${location.hash}`, "inbox", ), [surfaceLabel, 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!, { includeArchived: true }), queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }), enabled: !!selectedCompanyId, }); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), enabled: !!selectedCompanyId, }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); useEffect(() => { setBreadcrumbs([{ label: surfaceLabel }]); }, [setBreadcrumbs, surfaceLabel]); useEffect(() => { saveLastInboxTab(tab); setSelectedIndex(-1); setSearchQuery(""); }, [tab]); const previousSelectedCompanyIdRef = useRef(selectedCompanyId); useEffect(() => { if (previousSelectedCompanyIdRef.current !== selectedCompanyId) { previousSelectedCompanyIdRef.current = selectedCompanyId; const preferences = loadInboxCollectionPreferences(selectedCompanyId); setFilterPreferences(preferences.viewState); setVisibleIssueColumns(preferences.columns); setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId)); setCollapsedInboxParents(loadCollapsedInboxParentIds(selectedCompanyId)); } }, [selectedCompanyId]); 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 dashboardQueryKey = queryKeys.dashboard(selectedCompanyId!); const sharedDashboard = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "dashboard", queryKey: dashboardQueryKey, enabled: !!selectedCompanyId, }); const { data: dashboard, isLoading: isDashboardLoading, dataUpdatedAt: dashboardUpdatedAt } = useQuery({ queryKey: dashboardQueryKey, queryFn: () => dashboardApi.summary(selectedCompanyId!), enabled: !!selectedCompanyId, }); usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt); const inboxIssuesQueryKey = [...queryKeys.issues.list(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedInboxIssues = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "inbox:issues", queryKey: inboxIssuesQueryKey, enabled: !!selectedCompanyId, }); const { data: issues, isLoading: isIssuesLoading, dataUpdatedAt: issuesUpdatedAt } = useQuery({ queryKey: inboxIssuesQueryKey, queryFn: () => issuesApi.listCompact(selectedCompanyId!, { includeRoutineExecutions: true, includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId, refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); usePublishSharedQueryData(sharedInboxIssues, issues, issuesUpdatedAt); const { data: mineIssuesRaw = [], isLoading: isMineIssuesLoading, dataUpdatedAt: mineIssuesUpdatedAt, } = useQuery({ queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, queryFn: () => issuesApi.listCompact(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId, refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedMineIssues = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "inbox:mine-issues", queryKey: mineIssuesQueryKey, enabled: !!selectedCompanyId, }); usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt); const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, dataUpdatedAt: touchedIssuesUpdatedAt, } = useQuery({ queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, queryFn: () => issuesApi.listCompact(selectedCompanyId!, { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }).then((rows) => rows as Issue[]), enabled: !!selectedCompanyId, refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedTouchedIssues = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "inbox:touched-issues", queryKey: touchedIssuesQueryKey, enabled: !!selectedCompanyId, }); usePublishSharedQueryData(sharedTouchedIssues, touchedIssuesRaw, touchedIssuesUpdatedAt); const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT], queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); const sharedLiveRuns = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId, // Event-sourced via LiveUpdatesProvider (GitHub issue 9627); no interval poll needed. refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ queryKey: liveRunsQueryKey, queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: sharedLiveRuns.enabled, refetchInterval: sharedLiveRuns.refetchInterval, }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); const currentUserId = session?.user.id ?? session?.session.userId ?? null; const [archivingIssueIds, setArchivingIssueIds] = useState>(new Set()); const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState([]); const [unarchivingIssueIds, setUnarchivingIssueIds] = useState>(new Set()); const guardedArchiveIssueIds = useLocalInboxArchiveIssueIds(selectedCompanyId); const locallyArchivedIssueIds = useMemo(() => { const issueIds = new Set(guardedArchiveIssueIds); for (const issueId of undoableArchiveIssueIds) issueIds.add(issueId); for (const issueId of archivingIssueIds) issueIds.add(issueId); for (const issueId of unarchivingIssueIds) issueIds.delete(issueId); return issueIds; }, [archivingIssueIds, guardedArchiveIssueIds, undoableArchiveIssueIds, unarchivingIssueIds]); const companyUserLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); const companyUserProfileMap = useMemo( () => buildCompanyUserProfileMap(companyMembers?.users), [companyMembers?.users], ); const mineIssues = useMemo( () => getRecentTouchedIssues(mineIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)), [locallyArchivedIssueIds, mineIssuesRaw], ); const touchedIssues = useMemo( () => getRecentTouchedIssues(touchedIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)), [locallyArchivedIssueIds, touchedIssuesRaw], ); const shouldUseIssueSearchSupplement = !!selectedCompanyId && normalizedSearchQuery.length > 0; const { data: remoteIssueSearchResults = [] } = useQuery({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "compact", "inbox-supplement", "live-descendant-summary", ], queryFn: () => issuesApi.listCompact(selectedCompanyId!, { q: normalizedSearchQuery, limit: 25, includeRoutineExecutions: true, includeLiveDescendantSummary: true, }).then((rows) => rows as Issue[]), enabled: shouldUseIssueSearchSupplement, placeholderData: (previousData) => previousData, }); const liveIssueIds = useMemo( () => collectLiveIssueIds(liveRuns, [ ...(issues ?? []), ...mineIssuesRaw, ...touchedIssuesRaw, ...remoteIssueSearchResults, ]), [issues, liveRuns, mineIssuesRaw, remoteIssueSearchResults, touchedIssuesRaw], ); const inboxIssueIdsForExternalObjectSummaries = useMemo(() => { const issueIds = new Set(); for (const issue of mineIssues) issueIds.add(issue.id); for (const issue of touchedIssues) issueIds.add(issue.id); for (const issue of remoteIssueSearchResults) issueIds.add(issue.id); return [...issueIds]; }, [mineIssues, remoteIssueSearchResults, touchedIssues]); const { summaries: externalObjectSummaryByIssueId, isLoading: externalObjectSummariesLoading, isReady: externalObjectSummariesReady, } = useIssueExternalObjectSummaries( selectedCompanyId, inboxIssueIdsForExternalObjectSummaries, ); const issueFilterContext = useMemo(() => ({ externalObjectSummaryByIssueId, externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]); const visibleMineIssues = useMemo( () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), [mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const visibleTouchedIssues = useMemo( () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), [touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const unreadTouchedIssues = useMemo( () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), [visibleTouchedIssues], ); const creatorOptions = useMemo(() => { const options = new Map(); const sourceIssues = [...mineIssues, ...touchedIssues]; if (currentUserId) { options.set(`user:${currentUserId}`, { id: `user:${currentUserId}`, label: currentUserId === "local-board" ? "Board" : "Me", kind: "user", searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, }); } for (const issue of sourceIssues) { if (issue.createdByUserId) { const id = `user:${issue.createdByUserId}`; if (!options.has(id)) { options.set(id, { id, label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), kind: "user", searchText: `${issue.createdByUserId} board user human`, }); } } } const knownAgentIds = new Set(); for (const agent of agents ?? []) { knownAgentIds.add(agent.id); const id = `agent:${agent.id}`; if (!options.has(id)) { options.set(id, { id, label: agent.name, kind: "agent", searchText: `${agent.name} ${agent.id} agent`, }); } } for (const issue of sourceIssues) { if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { const id = `agent:${issue.createdByAgentId}`; if (!options.has(id)) { options.set(id, { id, label: issue.createdByAgentId.slice(0, 8), kind: "agent", searchText: `${issue.createdByAgentId} agent`, }); } } } return [...options.values()].sort((a, b) => { if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; return a.label.localeCompare(b.label); }); }, [agents, currentUserId, mineIssues, touchedIssues]); const issuesToRender = useMemo( () => { if (tab === "mine") return visibleMineIssues; if (tab === "unread") return unreadTouchedIssues; return visibleTouchedIssues; }, [tab, visibleMineIssues, visibleTouchedIssues, 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, projectId: project.id }); } } 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) { const projectWorkspace = workspace.projectWorkspaceId ? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null : null; map.set(workspace.id, { name: workspace.name, mode: workspace.mode, projectWorkspaceId: workspace.projectWorkspaceId ?? null, projectId: projectWorkspace?.projectId ?? null, }); } return map; }, [executionWorkspaces, projectWorkspaceById]); const inboxWorkspaceGrouping = useMemo( () => ({ agentById, executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, projectById, userLabelById: companyUserLabelMap, currentUserId, }), [ agentById, companyUserLabelMap, currentUserId, defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectById, projectWorkspaceById, ], ); 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 visibleTaskDataColumns = useMemo( () => visibleTrailingIssueColumns.filter((column) => column !== "updated"), [visibleTrailingIssueColumns], ); const showTaskTimestamp = visibleIssueColumnSet.has("updated") && availableIssueColumnSet.has("updated"); const failedRuns = useMemo( () => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter( (r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt), ), [heartbeatRuns, dismissedAtByKey], ); const approvalsToRender = useMemo(() => { let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId); if (tab === "mine") { filtered = filtered.filter( (a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt), ); } return filtered; }, [approvals, tab, allApprovalFilter, currentUserId, dismissedAtByKey]); 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 []; if (tab === "mine") return failedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)); return failedRuns; }, [failedRuns, tab, showFailedRunsCategory, currentUserId]); const joinRequestsForTab = useMemo(() => { if (tab === "all" && !showJoinRequestsCategory) return []; if (tab === "mine") { return joinRequests.filter( (jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt), ); } return joinRequests; }, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]); 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 = normalizedSearchQuery.toLowerCase(); if (!q) return workItemsToRender; return workItemsToRender.filter((item) => { if (item.kind === "issue") { return matchesInboxIssueSearch(item.issue, q, { isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, }); } 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, agentById, defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, issueById, isolatedWorkspacesEnabled, normalizedSearchQuery, projectWorkspaceById, ]); const archivedSearchIssues = useMemo( () => tab === "mine" ? getArchivedInboxSearchIssues({ visibleIssues: visibleMineIssues, searchableIssues: visibleTouchedIssues, query: normalizedSearchQuery, isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, }) : [], [ defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, isolatedWorkspacesEnabled, normalizedSearchQuery, projectWorkspaceById, tab, visibleMineIssues, visibleTouchedIssues, ], ); const issueSearchSupplementResults = useMemo( () => getInboxSearchSupplementIssues({ query: normalizedSearchQuery, filteredWorkItems, archivedSearchIssues, remoteIssues: remoteIssueSearchResults, issueFilters, currentUserId, enableRoutineVisibilityFilter: true, liveIssueIds, issueFilterContext, }), [ archivedSearchIssues, currentUserId, filteredWorkItems, issueFilterContext, issueFilters, liveIssueIds, normalizedSearchQuery, remoteIssueSearchResults, ], ); const nonInboxSearchIssueIds = useMemo( () => new Set([ ...archivedSearchIssues.map((issue) => issue.id), ...issueSearchSupplementResults.map((issue) => issue.id), ]), [archivedSearchIssues, issueSearchSupplementResults], ); // --- Parent-child nesting for inbox issues --- const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting()); const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile); useEffect(() => { if (!shouldResetInboxWorkspaceGrouping(groupBy, isolatedWorkspacesEnabled, experimentalSettingsLoaded)) return; setGroupBy("none"); saveInboxWorkItemGroupBy("none"); }, [experimentalSettingsLoaded, groupBy, isolatedWorkspacesEnabled]); const toggleNesting = useCallback(() => { setNestingPreferenceEnabled((prev) => { const next = !prev; saveInboxNesting(next); return next; }); }, []); const [collapsedInboxParents, setCollapsedInboxParents] = useState>( () => loadCollapsedInboxParentIds(selectedCompanyId), ); const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => loadCollapsedInboxGroupKeys(selectedCompanyId)); const toggleGroupCollapse = useCallback((groupKey: string) => { setCollapsedGroupKeys((prev) => { const next = new Set(prev); if (next.has(groupKey)) next.delete(groupKey); else next.add(groupKey); saveCollapsedInboxGroupKeys(selectedCompanyId, next); return next; }); }, [selectedCompanyId]); const setGroupCollapsed = useCallback((groupKey: string, collapsed: boolean) => { setCollapsedGroupKeys((prev) => { if (collapsed ? prev.has(groupKey) : !prev.has(groupKey)) return prev; const next = new Set(prev); if (collapsed) next.add(groupKey); else next.delete(groupKey); saveCollapsedInboxGroupKeys(selectedCompanyId, next); return next; }); }, [selectedCompanyId]); const freshGroupedSections = useMemo(() => [ ...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }), ...buildGroupedInboxSections( getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }), groupBy, inboxWorkspaceGrouping, { keyPrefix: "archived-search:", searchSection: "archived", nestingEnabled }, ), ...buildGroupedInboxSections( getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }), groupBy, inboxWorkspaceGrouping, { keyPrefix: "other-search:", searchSection: "other", nestingEnabled }, ), ], [ archivedSearchIssues, filteredWorkItems, groupBy, inboxWorkspaceGrouping, issueSearchSupplementResults, nestingEnabled, ]); // --- Order pinning (PAP-16015) --- // The freshly computed sort is only *displayed* at attention boundaries. Between // them we reconcile the fresh sections against the last committed order so that // archiving mid-engagement (which drops rows from the caches, and can lower a // parent's subtree-max sort ts) can no longer reshuffle the list under the // cursor. New items still land at their algorithmic position; archived rows hold // their slot for the 5s undo grace before collapsing. See `inboxOrderPin.ts`. const orderPinRef = useRef({ sections: [] }); // Bumped by an attention commit to force `groupedSections` to adopt the fresh order. const [orderCommitToken, setOrderCommitToken] = useState(0); const orderCommitRequestedRef = useRef(false); const commitInboxOrder = useCallback(() => { orderCommitRequestedRef.current = true; setOrderCommitToken((token) => token + 1); }, []); // Distinct view = distinct pin. Switching tab/filter/search/grouping resets the // held order so we never carry one view's layout into another. const inboxSortViewIdentity = useMemo( () => JSON.stringify([ selectedCompanyId, tab, groupBy, nestingEnabled, normalizedSearchQuery, allCategoryFilter, allApprovalFilter, issueFilters, ]), [ allApprovalFilter, allCategoryFilter, groupBy, issueFilters, nestingEnabled, normalizedSearchQuery, selectedCompanyId, tab, ], ); // Adopt the fresh order in the SAME render the view identity changes, so a // section key shared between the two views never briefly shows the previous // view's order before the hook's passive commit effect runs. const previousInboxSortViewIdentityRef = useRef(inboxSortViewIdentity); if (previousInboxSortViewIdentityRef.current !== inboxSortViewIdentity) { previousInboxSortViewIdentityRef.current = inboxSortViewIdentity; orderCommitRequestedRef.current = true; } const groupedSections = useMemo(() => { const nowMs = Date.now(); // An attention boundary (or a view change) fired: adopt the fresh order // wholesale and re-pin it. if (orderCommitRequestedRef.current) { orderCommitRequestedRef.current = false; orderPinRef.current = captureInboxOrderPin(freshGroupedSections); return freshGroupedSections; } const { sections, pin } = reconcileInboxOrderPin(orderPinRef.current, freshGroupedSections, nowMs); orderPinRef.current = pin; return sections; // orderCommitToken forces re-adoption at a commit boundary; inboxSortViewIdentity // forces it on a view change even when the fresh sections keep their identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [freshGroupedSections, inboxSortViewIdentity, orderCommitToken]); const inboxSortAttention = useInboxSortAttention({ viewIdentity: inboxSortViewIdentity, onCommit: commitInboxOrder, }); const noteInboxSortInteraction = inboxSortAttention.noteInteraction; const openCreateIssueForGroup = useCallback((group: InboxGroupedSection) => { const defaults = buildInboxIssueGroupCreateDefaults( group.key, groupBy, group.displayItems, inboxWorkspaceGrouping, ); if (!defaults) return; openNewIssue(defaults); }, [groupBy, inboxWorkspaceGrouping, openNewIssue]); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections], ); const toggleInboxParentCollapse = useCallback((parentId: string) => { setCollapsedInboxParents((prev) => { const next = new Set(prev); if (next.has(parentId)) next.delete(parentId); else next.add(parentId); saveCollapsedInboxParentIds(selectedCompanyId, next); return next; }); }, [selectedCompanyId]); const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => { setCollapsedInboxParents((prev) => { if (prev.has(parentId) === collapsed) return prev; const next = new Set(prev); if (collapsed) next.add(parentId); else next.delete(parentId); saveCollapsedInboxParentIds(selectedCompanyId, next); return next; }); }, [selectedCompanyId]); // Build flat navigation list from visible rows so keyboard traversal respects collapsed groups. const flatNavItems = useMemo((): NavEntry[] => { return buildInboxKeyboardNavEntries(groupedSections, collapsedGroupKeys, collapsedInboxParents); }, [collapsedGroupKeys, collapsedInboxParents, groupedSections]); // Read the current nav list from event handlers without recreating them (and // without capturing a stale array), so hover can resolve the row's key. const flatNavItemsRef = useRef(flatNavItems); flatNavItemsRef.current = flatNavItems; // Roll live descendant runs up to their ancestors across the loaded inbox tree // so a parent that is not itself live can still surface "n live below". const subtreeLiveCounts = useMemo(() => { const nodes: { id: string; parentId: string | null }[] = []; const seen = new Set(); const pushIssue = (issue: Issue) => { if (seen.has(issue.id)) return; seen.add(issue.id); nodes.push({ id: issue.id, parentId: issue.parentId }); }; for (const group of groupedSections) { for (const item of group.displayItems) { if (item.kind === "issue") pushIssue(item.issue); } for (const children of group.childrenByIssueId.values()) { for (const child of children) pushIssue(child); } } return collectSubtreeLiveCounts(nodes, liveIssueIds); }, [groupedSections, liveIssueIds]); const topFlatIndex = useMemo(() => { const map = new Map(); flatNavItems.forEach((entry, index) => { if (entry.type === "top") map.set(entry.itemKey, index); }); return map; }, [flatNavItems]); const childFlatIndex = useMemo(() => { const map = new Map(); flatNavItems.forEach((entry, index) => { if (entry.type === "child") map.set(entry.issueId, index); }); return map; }, [flatNavItems]); const groupFlatIndex = useMemo(() => { const map = new Map(); flatNavItems.forEach((entry, index) => { if (entry.type === "group") map.set(entry.groupKey, index); }); return map; }, [flatNavItems]); 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); saveTaskCollectionPreferences(inboxCollectionPreferenceLocation(selectedCompanyId), { viewState: filterPreferences, columns: normalized, }); }, [filterPreferences, selectedCompanyId]); const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => { if (enabled) { setIssueColumns([...visibleIssueColumns, column]); return; } setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); }, [setIssueColumns, visibleIssueColumns]); const updateFilterPreferences = useCallback( (updater: (previous: StreamlinedInboxViewState) => StreamlinedInboxViewState) => { setFilterPreferences((previous) => { const next = updater(previous); saveTaskCollectionPreferences(inboxCollectionPreferenceLocation(selectedCompanyId), { viewState: next, columns: visibleIssueColumns, }); return next; }); }, [selectedCompanyId, visibleIssueColumns], ); const updateIssueFilters = useCallback((patch: Partial) => { updateFilterPreferences((previous) => ({ ...previous, issueFilters: { ...previous.issueFilters, ...patch }, })); }, [updateFilterPreferences]); useEffect(() => { if (!experimentalSettingsLoaded || externalObjectsEnabled || issueFilters.externalObjectStatuses.length === 0) return; updateIssueFilters({ externalObjectStatuses: [] }); }, [ experimentalSettingsLoaded, externalObjectsEnabled, issueFilters.externalObjectStatuses.length, updateIssueFilters, ]); const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => { updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value })); }, [updateFilterPreferences]); const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => { updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value })); }, [updateFilterPreferences]); const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => { setGroupBy(nextGroupBy); saveInboxWorkItemGroupBy(nextGroupBy); }, []); 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 result = await agentsApi.retryFailedRun( run.agentId, run.id, run.companyId, ); 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) }); if (newRun.runId) navigate(`/agents/${originalRun.agentId}/runs/${newRun.runId}`); else if (newRun.issueId) navigate(`/issues/${newRun.issueId}`); }, onError: (error) => { pushToast({ title: "Run retry failed", body: error instanceof Error ? error.message : "Unable to retry run", tone: "error", }); }, 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 [fadingNonIssueItems, setFadingNonIssueItems] = useState>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState>(new Set()); const [selectedIndex, setSelectedIndex] = useState(-1); const listRef = useRef(null); // Keyboard nav scrolls the list, which fires mouseenter on whatever row lands // under the stationary cursor — that must not steal the selection. Hover only // selects after the pointer has physically moved since the last key nav. const pointerMovedSinceKeyNavRef = useRef(true); useEffect(() => { const handlePointerMove = () => { pointerMovedSinceKeyNavRef.current = true; }; window.addEventListener("mousemove", handlePointerMove, { passive: true }); return () => window.removeEventListener("mousemove", handlePointerMove); }, []); // Which row the cursor is over, tracked WITHOUT React state so scrubbing the // list costs zero re-renders (hover paints via CSS `:hover`, see IssueRow). // Keyboard nav reads this to continue from the hovered row. const hoveredIndexRef = useRef(null); // The hovered row's stable key, kept alongside the numeric index so a poll // that reshapes the list can re-anchor the hover to the same row instead of // dropping it (which stranded j/k back at the top — PAP-9679 regression). const hoveredNavKeyRef = useRef(null); const setSelectedIndexFromPointer = useCallback((idx: number) => { if (!pointerMovedSinceKeyNavRef.current) return; noteInboxSortInteraction(); hoveredIndexRef.current = idx; hoveredNavKeyRef.current = navEntryKey(flatNavItemsRef.current[idx]); // Drop any keyboard selection band the moment the mouse takes over, so we // never show two identical highlights at once. React bails out when the // value is already -1, so continuous hovering triggers no re-render. setSelectedIndex((prev) => (prev < 0 ? prev : -1)); }, [noteInboxSortInteraction]); const invalidateInboxIssueQueryCaches = () => { if (!selectedCompanyId) return; invalidateInboxIssueQueries(queryClient, selectedCompanyId); }; const archiveIssueMutation = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), onMutate: async (id) => { // Keep the sort pinned: archiving is engagement, so defer any idle re-sort. noteInboxSortInteraction(); setActionError(null); setArchivingIssueIds((prev) => new Set(prev).add(id)); if (!selectedCompanyId) return { previousData: [] as InboxIssueCacheSnapshot }; beginLocalInboxArchive(selectedCompanyId, id); await cancelInboxIssueQueries(queryClient, selectedCompanyId); const previousData = snapshotInboxIssueCaches(queryClient, selectedCompanyId); removeIssueFromInboxCaches(queryClient, selectedCompanyId, id); return { companyId: selectedCompanyId, previousData }; }, onError: (err, id, context) => { setActionError(err instanceof Error ? err.message : "Failed to archive task"); if (context?.companyId) clearLocalInboxArchive(context.companyId, id); setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); // Restore only this failed archive so overlapping archive mutations stay removed. if (context?.previousData) { restoreIssueToInboxCaches(queryClient, context.previousData, id); } }, onSettled: async (_data, error, id, context) => { // Clean up archiving state and refetch to sync with server setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); if (!context?.companyId) return; if (!error) boundLocalInboxArchive(context.companyId, id); await invalidateInboxIssueQueries(queryClient, context.companyId); if (!error) { const presence = getIssuePresenceInActiveInboxCaches(queryClient, context.companyId, id); if (presence !== "unknown") confirmLocalInboxArchive(context.companyId, id); } }, onSuccess: (_data, id) => { setUndoableArchiveIssueIds((prev) => [...prev.filter((issueId) => issueId !== id), id]); }, }); const unarchiveIssueMutation = useMutation({ mutationFn: (id: string) => issuesApi.unarchiveFromInbox(id), onMutate: (id) => { setActionError(null); setUnarchivingIssueIds((prev) => new Set(prev).add(id)); if (selectedCompanyId) clearLocalInboxArchive(selectedCompanyId, id); return { companyId: selectedCompanyId }; }, onError: (err, id, context) => { setActionError(err instanceof Error ? err.message : "Failed to undo inbox archive"); if (context?.companyId) { beginLocalInboxArchive(context.companyId, id); boundLocalInboxArchive(context.companyId, id); } }, onSuccess: (_data, id) => { setUndoableArchiveIssueIds((prev) => { const next = prev.filter((issueId) => issueId !== id); return next; }); }, onSettled: (_data, _error, id) => { setUnarchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); invalidateInboxIssueQueryCaches(); }, }); const markReadMutation = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onMutate: (id) => { setFadingOutIssues((prev) => new Set(prev).add(id)); }, onSuccess: () => { invalidateInboxIssueQueryCaches(); }, 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: () => { invalidateInboxIssueQueryCaches(); }, 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: () => { invalidateInboxIssueQueryCaches(); }, }); 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) => { noteInboxSortInteraction(); setArchivingNonIssueIds((prev) => new Set(prev).add(key)); setTimeout(() => { if (key.startsWith("alert:")) { dismissAlert(key); } else { dismissInboxItem(key); } setArchivingNonIssueIds((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, 200); }, [dismissAlert, dismissInboxItem, noteInboxSortInteraction]); 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"; }; // Keep selection on the same logical item when the list shape changes — // rows archived/refreshed above the selection would otherwise shift the // numeric index onto a neighboring row (and Enter would open the wrong // task). Falls back to clamping when the item is gone; never auto-selects // on initial load. const selectedNavKeyRef = useRef(null); useEffect(() => { // A reshaped list invalidates the numeric hover index. Re-anchor it to the // same row by key (the inbox polls constantly, so nulling it here silently // broke hover→j/k sync — PAP-9679). Drop it only when the row is gone. const hoveredKey = hoveredNavKeyRef.current; const nextHovered = hoveredKey === null ? -1 : flatNavItems.findIndex((entry) => navEntryKey(entry) === hoveredKey); hoveredIndexRef.current = nextHovered >= 0 ? nextHovered : null; if (nextHovered < 0) hoveredNavKeyRef.current = null; setSelectedIndex((prev) => { if (prev < 0) return resolveInboxSelectionIndex(prev, flatNavItems.length); const prevKey = selectedNavKeyRef.current; const keyIndex = prevKey === null ? -1 : flatNavItems.findIndex((entry) => navEntryKey(entry) === prevKey); return keyIndex >= 0 ? keyIndex : resolveInboxSelectionIndex(prev, flatNavItems.length); }); }, [flatNavItems]); useEffect(() => { selectedNavKeyRef.current = selectedIndex >= 0 ? navEntryKey(flatNavItems[selectedIndex]) : null; }, [flatNavItems, selectedIndex]); useEffect(() => { setUndoableArchiveIssueIds((prev) => prev.filter((issueId) => guardedArchiveIssueIds.has(issueId) || unarchivingIssueIds.has(issueId)), ); }, [guardedArchiveIssueIds, unarchivingIssueIds]); useEffect(() => { setUndoableArchiveIssueIds([]); setUnarchivingIssueIds(new Set()); }, [selectedCompanyId]); // Use refs for keyboard handler to avoid stale closures const kbStateRef = useRef({ workItems: groupedSections, flatNavItems, selectedIndex, canArchive: canArchiveFromTab, nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, archivingNonIssueIds, fadingOutIssues, readItems, }); kbStateRef.current = { workItems: groupedSections, flatNavItems, selectedIndex, canArchive: canArchiveFromTab, nonInboxSearchIssueIds, archivingIssueIds, undoableArchiveIssueIds, unarchivingIssueIds, archivingNonIssueIds, fadingOutIssues, readItems, }; const kbActionsRef = useRef({ archiveIssue: (id: string) => archiveIssueMutation.mutate(id), undoArchiveIssue: (id: string) => unarchiveIssueMutation.mutate(id), archiveNonIssue: handleArchiveNonIssue, markRead: (id: string) => markReadMutation.mutate(id), markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, setGroupCollapsed, setInboxParentCollapsed, navigate, }); kbActionsRef.current = { archiveIssue: (id: string) => archiveIssueMutation.mutate(id), undoArchiveIssue: (id: string) => unarchiveIssueMutation.mutate(id), archiveNonIssue: handleArchiveNonIssue, markRead: (id: string) => markReadMutation.mutate(id), markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, setGroupCollapsed, setInboxParentCollapsed, 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) || isKeyboardShortcutTextInputTarget(target) || hasBlockingShortcutDialog(document) || e.metaKey || e.ctrlKey || e.altKey ) { return; } const st = kbStateRef.current; const act = kbActionsRef.current; // Navigation works on every tab; archive/undo (and a/y below) stay // scoped to the "mine" tab, the only place items are archivable. const undoArchiveAction = !st.canArchive ? "none" : resolveInboxUndoArchiveKeyAction({ hasUndoableArchive: st.undoableArchiveIssueIds.length > 0, defaultPrevented: e.defaultPrevented, key: e.key, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey, target, hasOpenDialog: hasBlockingShortcutDialog(document), }); if (undoArchiveAction === "undo_archive") { const issueId = st.undoableArchiveIssueIds[st.undoableArchiveIssueIds.length - 1]; if (!issueId || st.unarchivingIssueIds.has(issueId)) return; e.preventDefault(); act.undoArchiveIssue(issueId); return; } const navItems = st.flatNavItems; const navCount = navItems.length; if (navCount === 0) return; // Any inbox keystroke (nav, archive, undo) is engagement: hold the sort. noteInboxSortInteraction(); /** Resolve the nav entry at an index 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 }; if (entry.type === "top") return { item: entry.item }; return {}; }; // The row a keystroke acts on: the hovered row when the mouse has moved // since the last key nav (so "hover a row → press a/r/Enter/Arrow" acts on // it), otherwise the keyboard selection. Hover no longer writes selection // state, so this is what threads the pointer position into every handler. const rawHovered = hoveredIndexRef.current; const hoveredIndex = rawHovered != null && rawHovered >= 0 && rawHovered < navCount ? rawHovered : -1; const fromHover = pointerMovedSinceKeyNavRef.current && hoveredIndex >= 0; const effectiveIndex = fromHover ? hoveredIndex : st.selectedIndex; switch (e.key) { case "j": case "ArrowDown": { e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedIndex(getInboxKeyboardSelectionIndex(effectiveIndex, navCount, "next")); break; } case "k": case "ArrowUp": { e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedIndex(getInboxKeyboardSelectionIndex(effectiveIndex, navCount, "previous")); break; } case "ArrowLeft": case "ArrowRight": { if (effectiveIndex < 0 || effectiveIndex >= navCount) return; const entry = navItems[effectiveIndex]; if (!entry) return; if (entry.type === "group") { e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedIndex(effectiveIndex); act.setGroupCollapsed(entry.groupKey, e.key === "ArrowLeft"); break; } // Parent tasks collapse/expand with the same keys as groups. const { issue, item } = resolveNavEntry(effectiveIndex); const targetIssue = issue ?? (item?.kind === "issue" ? item.issue : null); if (!targetIssue) return; const hasChildren = st.workItems.some( (group) => (group.childrenByIssueId.get(targetIssue.id)?.length ?? 0) > 0, ); if (!hasChildren) return; e.preventDefault(); pointerMovedSinceKeyNavRef.current = false; setSelectedIndex(effectiveIndex); act.setInboxParentCollapsed(targetIssue.id, e.key === "ArrowLeft"); break; } case "a": case "y": { if (!st.canArchive) return; if (effectiveIndex < 0 || effectiveIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(effectiveIndex); if (issue) { if (!st.nonInboxSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); } else if (item) { if (item.kind === "issue") { if (!st.nonInboxSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) { act.archiveIssue(item.issue.id); } } else { const key = getInboxWorkItemKey(item); if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key); } } break; } case "U": { if (!st.canArchive) return; if (effectiveIndex < 0 || effectiveIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(effectiveIndex); if (issue) { act.markUnreadIssue(issue.id); } else if (item) { if (item.kind === "issue") act.markUnreadIssue(item.issue.id); else act.markNonIssueUnread(getInboxWorkItemKey(item)); } break; } case "r": { if (!st.canArchive) return; if (effectiveIndex < 0 || effectiveIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(effectiveIndex); 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 = getInboxWorkItemKey(item); if (!st.readItems.has(key)) act.markNonIssueRead(key); } } break; } case "Enter": { if (effectiveIndex < 0 || effectiveIndex >= navCount) return; e.preventDefault(); const { issue, item } = resolveNavEntry(effectiveIndex); if (issue) { const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue)); rememberIssueDetailLocationState(pathId, detailState); void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { const pathId = item.issue.identifier ?? item.issue.id; const detailState = armIssueDetailInboxQuickArchive( withIssueDetailHeaderSeed(issueLinkState, item.issue), ); rememberIssueDetailLocationState(pathId, detailState); void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue }); 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); }, [issueLinkState, noteInboxSortInteraction]); // 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 showCompanyAlerts = shouldShowCompanyAlerts(tab) && showAlertsCategory; const showAggregateAgentError = showCompanyAlerts && !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissedAlerts.has("alert:agent-errors"); const showBudgetAlert = showCompanyAlerts && !!dashboard && dashboard.costs.monthBudgetCents > 0 && dashboard.costs.monthUtilizationPercent >= 80 && !dismissedAlerts.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const showWorkItemsSection = totalVisibleWorkItems > 0; const showAlertsSection = shouldShowInboxSection({ tab, hasItems: hasAlerts, showOnMine: false, showOnRecent: false, showOnUnread: false, showOnAll: 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" ? visibleMineIssues : 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; const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true); const activeInboxScopeFilterCount = tab === "all" ? Number(allCategoryFilter !== "everything") + Number(showApprovalsCategory && allApprovalFilter !== "all") : 0; const activeFilterCount = activeIssueFilterCount + activeInboxScopeFilterCount; const showGeneralIssueToolbarControls = tab !== "blocked"; const activeStatusFilterApplied = issueFilters.statuses.length === 4 && ["todo", "in_progress", "in_review", "blocked"].every((status) => issueFilters.statuses.includes(status as IssueFilterState["statuses"][number]), ); const issueFilterFeedback = issueFilters.liveOnly ? "Live runs only — tasks currently connected to an agent run." : activeStatusFilterApplied ? "Active statuses — open tasks, whether or not an agent is running." : null; return (
navigate(`/inbox/${value}`)}> )} search={(
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (shouldBlurPageSearchOnEnter({ key: e.key, isComposing: e.nativeEvent.isComposing, })) { e.currentTarget.blur(); return; } if (shouldBlurPageSearchOnEscape({ key: e.key, isComposing: e.nativeEvent.isComposing, currentValue: e.currentTarget.value, })) { e.currentTarget.blur(); } }} className="h-8 w-full pl-8 text-xs" data-page-search-target="true" />
)} controls={( <> {tab === "blocked" ? ( <> ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} />
{BLOCKED_GROUP_OPTIONS.map(([value, label]) => ( ))}
{ updateFilterPreferences((previous) => ({ ...previous, showDateGroupSeparators: enabled, })); }} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which columns stay visible" iconOnly />
{BLOCKED_SORT_OPTIONS.map(([value, label]) => ( ))}
) : showGeneralIssueToolbarControls ? ( <> ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} inboxScopeFilters={tab === "all" ? { category: allCategoryFilter, approvalStatus: allApprovalFilter, showApprovalStatus: showApprovalsCategory, onCategoryChange: updateAllCategoryFilter, onApprovalStatusChange: updateAllApprovalFilter, onClear: () => { updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: "everything", allApprovalFilter: "all", })); }, } : undefined} />
{([ ["none", "None"], ["type", "Type"], ["assignee", "Responsible"], ["project", "Project"], ...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []), ] as const).map(([value, label]) => ( ))}
{ updateFilterPreferences((previous) => ({ ...previous, showDateGroupSeparators: enabled, })); }} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which columns stay visible" iconOnly rowPresentation={streamlinedUiEnabled ? "task" : "legacy"} /> {canMarkAllRead && ( <> Mark all as read? This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read. )} ) : null} )} feedback={issueFilterFeedback ? (

{issueFilterFeedback}

) : null} /> {approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} {tab === "blocked" ? (
) : null} {tab !== "blocked" && !allLoaded && visibleSections.length === 0 && ( )} {tab !== "blocked" && allLoaded && visibleSections.length === 0 && ( )} {tab !== "blocked" && showWorkItemsSection && ( <> {showSeparatorBefore("work_items") && }
{(() => { const renderInboxIssue = ({ issue, depth, selected, hasChildren = false, isExpanded = false, childCount = 0, collapseParentId = null, allowArchive = canArchiveFromTab, }: { issue: Issue; depth: number; selected: boolean; hasChildren?: boolean; isExpanded?: boolean; childCount?: number; collapseParentId?: string | null; allowArchive?: boolean; }) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id); const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const assigneeUserProfile = issue.assigneeUserId ? companyUserProfileMap.get(issue.assigneeUserId) ?? null : null; const originatingActor = deriveOriginatingActor(issue); const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null; const originatingViaAgentId = originatingActor?.kind === "user" ? originatingActor.viaAgentId ?? null : null; const isLive = liveIssueIds.has(issue.id); const loadedSubtreeLiveCount = subtreeLiveCounts.get(issue.id) ?? 0; const liveDescendantCount = resolveIssueLiveDescendantCount(issue, loadedSubtreeLiveCount); const blockerAttention = resolveInboxIssueBlockerAttention(issue, { isLive, loadedSubtreeLiveCount, }); const showStatus = visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status"); const showSubtreeLiveChip = !( showStatus && issue.status === "blocked" && blockerAttention?.state === "covered" ); const rowStatusIcon = ( ); return ( 0 && hasChildren} selected={selected} className={ isArchiving ? "pointer-events-none -translate-x-4 scale-(--s-0_98) opacity-0 transition-all duration-200 ease-out" : "transition-all duration-200 ease-out" } leadingControl={streamlinedUiEnabled && nestingEnabled && hasChildren && collapseParentId ? ( ) : streamlinedUiEnabled ? (