import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled"; import { CoordinatorNeedsYou } from "../components/coordinator/CoordinatorNeedsYou"; import { CheckCircle2, Inbox } from "lucide-react"; import type { Agent, AttentionItem, AttentionSubject } from "@paperclipai/shared"; import { useNavigate, useSearchParams } from "@/lib/router"; import { attentionApi } from "../api/attention"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { decisionsApi } from "../api/decisions"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { useInboxDismissals } from "../hooks/useInboxBadge"; import { queryKeys } from "../lib/queryKeys"; import { ATTENTION_AGING_DAYS, attentionIsAging, buildAttentionFilterOptions, defaultAttentionFilterState, filterAttentionItems, groupAttentionItems, isInlineResolvable, loadAttentionFilters, loadAttentionGroupBy, loadAttentionSortOrder, loadCollapsedAttentionGroupKeys, buildDeskShelves, planAttentionRenderRows, resolveAttentionDateRange, saveAttentionFilters, saveAttentionGroupBy, saveAttentionSortOrder, saveCollapsedAttentionGroupKeys, sortAttentionItems, type AttentionDateRangeId, type AttentionFilterState, type AttentionGroup, type AttentionGroupBy, type AttentionSortOrder, } from "../lib/attention"; import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts"; import { PageSkeleton } from "../components/PageSkeleton"; import { AttentionQueueRow } from "../components/AttentionQueueRow"; import { DecisionsToolbar } from "../components/DecisionsToolbar"; import { Curtain, AgingItemRow } from "../components/DecisionShelf"; import { DecisionQueueRail } from "../components/DecisionQueueRail"; import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips"; import { DecisionResolver } from "../components/DecisionResolver"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; /** Curtain rows never expand; module-level so memoized rows see one identity. */ const noopToggleExpand = () => {}; // Incremental rendering (PAP-13784, same pattern as IssuesList): the feed is // uncapped, so mounting every row up front makes the page slow to paint and // scroll. Render a bounded window and grow it as the scroll position nears the // bottom. One budget spans the active groups and the open curtains in document // order, so everything below the fold stays unmounted until needed. const INITIAL_ATTENTION_ROW_RENDER_LIMIT = 50; const ATTENTION_ROW_RENDER_BATCH_SIZE = 100; const ATTENTION_SCROLL_LOAD_THRESHOLD_PX = 480; const DECISION_HISTORY_VISIBLE_LIMIT = 50; const DECISION_HISTORY_QUERY_LIMIT = DECISION_HISTORY_VISIBLE_LIMIT + 1; export function decisionHistoryQueryEnabled(companyId: string | null | undefined, open: boolean) { return Boolean(companyId && open); } export function decisionHistoryCount(count: number | undefined) { if (count == null) return undefined; return count > DECISION_HISTORY_VISIBLE_LIMIT ? `${DECISION_HISTORY_VISIBLE_LIMIT}+` : count; } function findScrollContainer(element: HTMLElement | null): HTMLElement | null { if (!element || typeof window === "undefined") return null; let current = element.parentElement; while (current && current !== document.body && current !== document.documentElement) { const overflowY = window.getComputedStyle(current).overflowY; if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") { return current; } current = current.parentElement; } return null; } export function WhatNeedsMe() { const { enabled } = useCoordinatorWorkspaceEnabled(); return enabled ? : ; } function ClassicWhatNeedsMe() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [expandedId, setExpandedId] = useState(null); const [selectedAttentionId, setSelectedAttentionId] = useState(null); // How the current selection was made. The selection ring is the keyboard // cursor — it marks the row that j/k, e, x and s will act on — so it is drawn // only for a keyboard-driven selection. Clicking used to set it too, which // put a ring around the card for no reason the operator could act on, and // only ever on rows with a See more/less toggle to click (the toggle is what // set it), so the queue looked arbitrarily inconsistent. The selection itself // still follows a click, so keyboard actions target the row you just used. const [selectionFromKeyboard, setSelectionFromKeyboard] = useState(false); const [autoExpandDone, setAutoExpandDone] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // Toolbar preferences (persisted to localStorage, Inbox pattern). const [groupBy, setGroupBy] = useState(() => loadAttentionGroupBy()); const [sortOrder, setSortOrder] = useState(() => loadAttentionSortOrder()); const [filters, setFilters] = useState(() => defaultAttentionFilterState); const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => new Set()); const [snoozedOpen, setSnoozedOpen] = useState(false); const [dismissedOpen, setDismissedOpen] = useState(false); const [agingOpen, setAgingOpen] = useState(false); const [decidedOpen, setDecidedOpen] = useState(false); const [expiredOpen, setExpiredOpen] = useState(false); // Date-range chips (PAP-16032 §4.2) — resolve to server-side activity bounds. const [dateRange, setDateRange] = useState("all"); const [customRange, setCustomRange] = useState({ from: null, to: null }); // `?decisionId=` deep link (PAP-16032 §4.7) — focus/expand the referenced card. const [searchParams, setSearchParams] = useSearchParams(); const deepLinkDecisionId = searchParams.get("decisionId"); const [deepLinkConsumed, setDeepLinkConsumed] = useState(false); // Optimistic hide/restore. Reset whenever a fresh feed lands (server truth). const [pendingHide, setPendingHide] = useState>(() => new Set()); const [pendingRestore, setPendingRestore] = useState>(() => new Set()); const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId); const { pushToast } = useToastActions(); const navigate = useNavigate(); // Date chips resolve to server-side activity bounds. Anchored to start-of-day, // so the resolved ISO strings are stable across renders within the same day — // safe to key the feed query on without thrashing. const activityBounds = useMemo( () => resolveAttentionDateRange(dateRange, Date.now(), customRange), [dateRange, customRange], ); useEffect(() => { setBreadcrumbs([{ label: "Decisions" }]); }, [setBreadcrumbs]); // Re-hydrate per-company preferences when the company changes. useEffect(() => { setFilters(loadAttentionFilters(selectedCompanyId)); setCollapsedGroupKeys(loadCollapsedAttentionGroupKeys(selectedCompanyId)); }, [selectedCompanyId]); const { data: feed, isLoading, error, } = useQuery({ // Distinct from the sidebar badge's `queryKeys.attention` so dismissed rows // (needed for the curtains) never inflate the badge count. Invalidating the // `["attention", companyId]` prefix still cascades to this query. queryKey: [ ...queryKeys.attention(selectedCompanyId!), "with-dismissed", activityBounds.activitySince ?? null, activityBounds.activityUntil ?? null, ], queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true, all: true, ...activityBounds, }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true, }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); // Decision history — decided / expired decisions leave the open attention // feed (entryRule = open only), so we fetch them directly for the curtains. const { data: decidedDecisions, isLoading: decidedDecisionsLoading } = useQuery({ queryKey: queryKeys.decisions.list(selectedCompanyId!, "decided"), queryFn: () => decisionsApi.list(selectedCompanyId!, { status: "decided", limit: DECISION_HISTORY_QUERY_LIMIT }), enabled: decisionHistoryQueryEnabled(selectedCompanyId, decidedOpen), }); const { data: expiredDecisions, isLoading: expiredDecisionsLoading } = useQuery({ queryKey: queryKeys.decisions.list(selectedCompanyId!, "expired"), queryFn: () => decisionsApi.list(selectedCompanyId!, { status: "expired", limit: DECISION_HISTORY_QUERY_LIMIT }), enabled: decisionHistoryQueryEnabled(selectedCompanyId, expiredOpen), }); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const agentMap = useMemo(() => { const map = new Map(); for (const agent of agents ?? []) map.set(agent.id, agent); return map; }, [agents]); // Reset optimistic state once the server sends a fresh snapshot. useEffect(() => { setPendingHide(new Set()); setPendingRestore(new Set()); }, [feed?.generatedAt]); const allItems = useMemo(() => feed?.items ?? [], [feed]); const isServerHidden = (item: AttentionItem) => item.dismissal != null && item.dismissal.isActive; const activeItems = useMemo( () => allItems.filter( (item) => (!isServerHidden(item) || pendingRestore.has(item.id)) && !pendingHide.has(item.id), ), [allItems, pendingHide, pendingRestore], ); // The server's clock at feed time — used for the arrival/decide-by shelves and // the aging idle labels so they match `deskBadgeCount` and the sidebar badge // exactly, and stay stable across renders (Date.now() only as a pre-load fallback). const now = useMemo( () => (feed?.generatedAt ? new Date(feed.generatedAt).getTime() : Date.now()), [feed?.generatedAt], ); // Aging shelf (§4.4): items the server flags as idle past retention leave the // live desk for their own curtain, so today's desk shows only fresh decisions. const agingItems = useMemo(() => activeItems.filter(attentionIsAging), [activeItems]); const deskItems = useMemo(() => activeItems.filter((item) => !attentionIsAging(item)), [activeItems]); const snoozedItems = useMemo( () => allItems.filter( (item) => item.dismissal?.kind === "snooze" && item.dismissal.isActive && !pendingRestore.has(item.id), ), [allItems, pendingRestore], ); const dismissedItems = useMemo( () => allItems.filter( (item) => item.dismissal?.kind === "dismiss" && item.dismissal.isActive && !pendingRestore.has(item.id), ), [allItems, pendingRestore], ); const filterOptions = useMemo(() => buildAttentionFilterOptions(deskItems), [deskItems]); // Filter → sort → group, all client-side so switching re-buckets without a // refetch. In the default (ungrouped) view the desk groups by arrival — // "New today" then "Earlier" — with a "Decide now" shelf only when something // carries an explicit, due decide-by. Any explicit // group-by keeps the Inbox-style activity grouping. const groups = useMemo(() => { const filtered = filterAttentionItems(deskItems, filters, searchQuery); if (groupBy === "none") { return buildDeskShelves(filtered, now); } const sorted = sortAttentionItems(filtered, sortOrder); return groupAttentionItems(sorted, groupBy); }, [deskItems, filters, searchQuery, sortOrder, groupBy, now]); const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]); const keyboardItems = useMemo( () => groups.filter((group) => group.label === null || !collapsedGroupKeys.has(group.key)).flatMap((group) => group.items), [collapsedGroupKeys, groups], ); // Rendered-row budget: only ratchets up (a hard reset mid-scroll would yank // the DOM out from under the user), and resets when the company changes. const [renderedRowLimit, setRenderedRowLimit] = useState(INITIAL_ATTENTION_ROW_RENDER_LIMIT); const rootRef = useRef(null); useEffect(() => { setRenderedRowLimit(INITIAL_ATTENTION_ROW_RENDER_LIMIT); }, [selectedCompanyId]); // Keyboard selection may point past the budget (e.g. wrapping to the last // row), so the effective limit is derived to always cover it — the selected // row is then guaranteed to be in the DOM in the same commit that selects it. const renderPlan = useMemo(() => { const selectedIndex = selectedAttentionId ? keyboardItems.findIndex((item) => item.id === selectedAttentionId) : -1; return planAttentionRenderRows({ groups, collapsedGroupKeys, snoozedItems, snoozedOpen, dismissedItems, dismissedOpen, limit: Math.max(renderedRowLimit, selectedIndex + 1), }); }, [ collapsedGroupKeys, dismissedItems, dismissedOpen, groups, keyboardItems, renderedRowLimit, selectedAttentionId, snoozedItems, snoozedOpen, ]); const loadMoreRows = useCallback(() => { setRenderedRowLimit((current) => current + ATTENTION_ROW_RENDER_BATCH_SIZE); }, []); useEffect(() => { if (!renderPlan.hasMoreRows) return; let animationFrameId: number | null = null; const scrollContainer = findScrollContainer(rootRef.current); const scrollTarget: Window | HTMLElement = scrollContainer ?? window; const checkScrollPosition = () => { if (animationFrameId !== null) return; animationFrameId = window.requestAnimationFrame(() => { animationFrameId = null; const scrollHeight = scrollContainer?.scrollHeight ?? document.documentElement.scrollHeight; if (scrollHeight === 0) return; const scrollBottom = scrollContainer ? scrollContainer.scrollTop + scrollContainer.clientHeight : window.scrollY + window.innerHeight; if (scrollBottom >= scrollHeight - ATTENTION_SCROLL_LOAD_THRESHOLD_PX) { loadMoreRows(); } }); }; scrollTarget.addEventListener("scroll", checkScrollPosition, { passive: true }); window.addEventListener("resize", checkScrollPosition); // Initial check: a tall viewport (or an opened curtain) may need more rows // than the current budget before any scrolling happens. checkScrollPosition(); return () => { scrollTarget.removeEventListener("scroll", checkScrollPosition); window.removeEventListener("resize", checkScrollPosition); if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId); }; }, [loadMoreRows, renderPlan.hasMoreRows, renderedRowLimit]); useEffect(() => { if (selectedAttentionId && !keyboardItems.some((item) => item.id === selectedAttentionId)) { setSelectedAttentionId(null); setSelectionFromKeyboard(false); } }, [keyboardItems, selectedAttentionId]); useEffect(() => { if (!selectedAttentionId) return; document.getElementById(`attention-row-${selectedAttentionId}`)?.scrollIntoView({ block: "nearest" }); }, [selectedAttentionId]); // `?decisionId=` deep link (§4.7): focus and expand the referenced decision // card once the feed lands, then drop the param so a later manual collapse is // not re-forced on the next refetch. Wins over the generic auto-expand below. useEffect(() => { if (deepLinkConsumed || !deepLinkDecisionId || allItems.length === 0) return; const target = allItems.find( (item) => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId, ); setDeepLinkConsumed(true); setAutoExpandDone(true); if (target) { setExpandedId(target.id); setSelectedAttentionId(target.id); setSelectionFromKeyboard(true); setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.delete("decisionId"); return next; }, { replace: true }, ); } }, [allItems, deepLinkConsumed, deepLinkDecisionId, setSearchParams]); // Auto-expand the topmost inline-capable decision, once. useEffect(() => { if (autoExpandDone || deskItems.length === 0) return; const sorted = sortAttentionItems(deskItems, sortOrder); const topInline = sorted.find((item) => isInlineResolvable(item)); if (topInline) setExpandedId(topInline.id); setAutoExpandDone(true); }, [deskItems, autoExpandDone, sortOrder]); const updateGroupBy = (next: AttentionGroupBy) => { setGroupBy(next); saveAttentionGroupBy(next); }; const updateSortOrder = (next: AttentionSortOrder) => { setSortOrder(next); saveAttentionSortOrder(next); }; const updateFilters = (next: AttentionFilterState) => { setFilters(next); saveAttentionFilters(selectedCompanyId, next); }; const toggleGroupCollapse = (key: string) => { setCollapsedGroupKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); saveCollapsedAttentionGroupKeys(selectedCompanyId, next); return next; }); }; // All row callbacks are stable (deps are setState functions, stable hook // callbacks, and the stable `pushToast`) so the memoized rows only re-render // when their own item/expanded/selected props change (PAP-13784). const handleUndoDismiss = useCallback( (item: AttentionItem) => { setPendingHide((prev) => { const next = new Set(prev); next.delete(item.id); return next; }); restore(item.dismissalKey); }, [restore], ); const handleDismiss = useCallback( (item: AttentionItem) => { setPendingHide((prev) => new Set(prev).add(item.id)); dismiss(item.dismissalKey); setExpandedId((previous) => (previous === item.id ? null : previous)); // ~8s undo window; restores the row in place via T1's DELETE endpoint. pushToast({ id: `attention-dismiss-${item.id}`, dedupeKey: `attention-dismiss-${item.dismissalKey}`, title: "Dismissed", body: item.subject.title ?? undefined, tone: "info", ttlMs: 8000, action: { label: "Undo", onClick: () => handleUndoDismiss(item) }, }); }, [dismiss, handleUndoDismiss, pushToast], ); const handleSnooze = useCallback( (item: AttentionItem, snoozedUntil: string) => { setPendingHide((prev) => new Set(prev).add(item.id)); snooze(item.dismissalKey, snoozedUntil); setExpandedId((previous) => (previous === item.id ? null : previous)); }, [snooze], ); const handleRestore = useCallback( (item: AttentionItem) => { setPendingRestore((prev) => new Set(prev).add(item.id)); restore(item.dismissalKey); }, [restore], ); const handleToggleExpand = useCallback((item: AttentionItem) => { setSelectedAttentionId(item.id); setSelectionFromKeyboard(false); setExpandedId((prev) => (prev === item.id ? null : item.id)); }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const action = resolveAttentionQueueKeyAction({ defaultPrevented: event.defaultPrevented, key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey, altKey: event.altKey, target: event.target, hasOpenDialog: hasBlockingShortcutDialog(document), hasSelection: selectedAttentionId !== null, }); if (action === "ignore" || keyboardItems.length === 0) return; if (action === "next" || action === "previous") { event.preventDefault(); const currentIndex = selectedAttentionId ? keyboardItems.findIndex((item) => item.id === selectedAttentionId) : -1; const offset = action === "next" ? 1 : -1; const nextIndex = currentIndex < 0 ? action === "next" ? 0 : keyboardItems.length - 1 : (currentIndex + offset + keyboardItems.length) % keyboardItems.length; setSelectedAttentionId(keyboardItems[nextIndex]?.id ?? null); setSelectionFromKeyboard(true); return; } const selectedItem = keyboardItems.find((item) => item.id === selectedAttentionId); if (!selectedItem) return; event.preventDefault(); if (action === "dismiss") { handleDismiss(selectedItem); } else if (isInlineResolvable(selectedItem)) { setExpandedId((previous) => (previous === selectedItem.id ? null : selectedItem.id)); } else if (selectedItem.subject.href) { navigate(selectedItem.subject.href); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleDismiss, keyboardItems, navigate, selectedAttentionId]); if (!selectedCompanyId) { return

Select an organization first.

; } if (isLoading) { return ; } const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0; return (

Decisions

{/* Queue quicklinks + date-range chips (§4.1–§4.2). The rail self-hides when the company has no queues; the chips filter the desk server-side. */}
{ setDateRange(value); setCustomRange(custom); }} />
{error &&

{(error as Error).message}

} {!hasAnything ? ( ) : (
{visibleCount === 0 ? ( 0} /> ) : ( <> {groups.map((group) => { const groupLabel = group.label; const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key); return (
{groupLabel !== null && ( toggleGroupCollapse(group.key)} trailing={ {group.items.length} } /> )} {!collapsed && (
{(() => { const rows = renderPlan.groupRows.get(group.key) ?? []; const seenBundles = new Set(); return rows.map((item) => { const bundleId = item.sourceKind === "decision" ? ((item.subject.metadata?.bundleId as string | null | undefined) ?? null) : null; let header: ReactNode = null; if (bundleId && !seenBundles.has(bundleId)) { seenBundles.add(bundleId); const bundleRows = rows.filter( (row) => row.sourceKind === "decision" && ((row.subject.metadata?.bundleId as string | null | undefined) ?? null) === bundleId, ); const first = bundleRows[0]; header = ( ); } return ( {header}
); }); })()}
)}
); })} )} {snoozedItems.length > 0 && ( setSnoozedOpen((prev) => !prev)} > {renderPlan.snoozedRows.map((item) => ( ))} )} {dismissedItems.length > 0 && ( setDismissedOpen((prev) => !prev)} > {renderPlan.dismissedRows.map((item) => ( ))} )} {agingItems.length > 0 && ( setAgingOpen((prev) => !prev)} >

Idle past {ATTENTION_AGING_DAYS} days — kept off the desk. Keep any you still want surfaced.

{agingItems.map((item) => ( ))}
)}
)}
setDecidedOpen((prev) => !prev)} > {decidedDecisionsLoading ? (

Loading decided decisions…

) : (decidedDecisions?.length ?? 0) > 0 ? ( decidedDecisions!.slice(0, DECISION_HISTORY_VISIBLE_LIMIT).map((decision) => ( )) ) : (

No decided decisions.

)}
setExpiredOpen((prev) => !prev)} > {expiredDecisionsLoading ? (

Loading expired decisions…

) : (expiredDecisions?.length ?? 0) > 0 ? ( expiredDecisions!.slice(0, DECISION_HISTORY_VISIBLE_LIMIT).map((decision) => ( )) ) : (

No expired decisions.

)}
); } /** * Violet left-rule strip over a run of decisions that share a bundle, e.g. * "Planner proposed 6 decisions · from PAP-123 · routing review · 6 pending". * Grouping is a surface only — each decision is still decided independently. */ export function DecisionBundleHeader({ agentName, title, originIssue, count, }: { agentName: string | null; title: string | null; originIssue: AttentionSubject | null; count: number; }) { const noun = count === 1 ? "decision" : "decisions"; return (
{agentName ?? "An agent"} proposed {count} {noun} {originIssue && (originIssue.identifier || originIssue.title) && ( {"· from "} {originIssue.href ? ( {originIssue.identifier ?? originIssue.title} ) : ( originIssue.identifier ?? originIssue.title )} )} {title && · {title}} · {count} pending
); } function CaughtUpNote({ filtered }: { filtered: boolean }) { return (

{filtered ? "No decisions match your search or filters." : "You're all caught up."}

{filtered && (

Adjust or clear your search and filters to see the rest.

)}
); } function ZeroState() { return (

You're all caught up

Nothing needs a decision from you right now.

); }