diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 09dfdf0ca0..14056b9411 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; -import type { AnchorHTMLAttributes, ReactElement } from "react"; +import { useState, type AnchorHTMLAttributes, type ReactElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared"; @@ -33,6 +33,13 @@ vi.mock("../api/issues", () => ({ }, })); +// Spy on `relativeTime` (called exactly once per active-row render) so the +// memoization test below can count row renders without a profiling build. +vi.mock("../lib/utils", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, relativeTime: vi.fn(original.relativeTime) }; +}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -441,6 +448,30 @@ describe("AttentionQueueRow", () => { expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center"); }); + it("is memoized — a parent re-render with identical props does not re-render the row", async () => { + const { relativeTime } = await import("../lib/utils"); + const item = buildItem(); + let bump: () => void = () => {}; + function Harness() { + const [, setTick] = useState(0); + bump = () => setTick((n) => n + 1); + return ( + + ); + } + render(); + const rendersAfterMount = vi.mocked(relativeTime).mock.calls.length; + expect(rendersAfterMount).toBeGreaterThan(0); + act(() => bump()); + expect(vi.mocked(relativeTime).mock.calls.length).toBe(rendersAfterMount); + }); + it("does not expose a toggle button for non-inline rows", () => { render( void; + /** Receives the row's item so the parent can pass one stable callback for every row. */ + onToggleExpand: (item: AttentionItem) => void; onDismiss: (item: AttentionItem) => void; onSnooze?: (item: AttentionItem, snoozedUntil: string) => void; /** Restore a snoozed/dismissed row (curtain variant only). */ @@ -78,7 +79,14 @@ interface AttentionQueueRowProps { selected?: boolean; } -export function AttentionQueueRow({ +/** + * Memoized (PAP-13784): the queue renders every feed row in one flat list, so + * without memo a single keyboard-selection or expand toggle re-renders every + * row (each carrying a Radix dropdown + mutation). All props are stable or + * primitive; `item` identity is preserved across refetches by react-query's + * structural sharing. + */ +export const AttentionQueueRow = memo(function AttentionQueueRow({ item, companyId, expanded, @@ -108,13 +116,13 @@ export function AttentionQueueRow({ const expandable = inline; const activate = () => { - if (expandable) onToggleExpand(); + if (expandable) onToggleExpand(item); }; const onHeaderKeyDown = (e: KeyboardEvent) => { if (!expandable) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onToggleExpand(); + onToggleExpand(item); } }; @@ -122,6 +130,10 @@ export function AttentionQueueRow({
- {!expanded && } + {!expanded && onToggleExpand(item)} />}
{!inline && href && ( @@ -292,7 +304,7 @@ export function AttentionQueueRow({ )}
); -} +}); type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision"; diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index a12c6c7add..b44eb90fde 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "../api/access"; import { ApiError } from "../api/client"; @@ -118,12 +118,24 @@ export function useInboxDismissals(companyId: string | null | undefined) { [dismissals], ); + // Stable identities (react-query keeps `mutate` referentially stable) so + // consumers can hand these to memoized rows without breaking memoization. + const dismissMutate = dismissMutation.mutate; + const snoozeMutate = snoozeMutation.mutate; + const restoreMutate = restoreMutation.mutate; + const dismiss = useCallback((itemKey: string) => dismissMutate({ itemKey }), [dismissMutate]); + const snooze = useCallback( + (itemKey: string, snoozedUntil: string) => snoozeMutate({ itemKey, snoozedUntil }), + [snoozeMutate], + ); + const restore = useCallback((itemKey: string) => restoreMutate({ itemKey }), [restoreMutate]); + return { dismissals, dismissedAtByKey, - dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }), - snooze: (itemKey: string, snoozedUntil: string) => snoozeMutation.mutate({ itemKey, snoozedUntil }), - restore: (itemKey: string) => restoreMutation.mutate({ itemKey }), + dismiss, + snooze, + restore, isPending: dismissMutation.isPending || snoozeMutation.isPending || restoreMutation.isPending, }; } diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index a5f5db5c48..a91b662e25 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -16,6 +16,7 @@ import { isInlineResolvable, loadAttentionGroupBy, NO_GROUP_SENTINEL, + planAttentionRenderRows, saveAttentionGroupBy, severityBadge, severityStyle, @@ -401,3 +402,77 @@ describe("buildAttentionFilterOptions", () => { expect(options.hasNoWorkspace).toBe(true); }); }); + +describe("planAttentionRenderRows (PAP-13784 incremental rendering)", () => { + const items = (prefix: string, count: number) => + Array.from({ length: count }, (_, i) => buildItem({ id: `${prefix}${i}` })); + + it("allocates the budget across groups in document order", () => { + const plan = planAttentionRenderRows({ + groups: [ + { key: "g1", label: "One", items: items("a", 3) }, + { key: "g2", label: "Two", items: items("b", 3) }, + ], + collapsedGroupKeys: new Set(), + snoozedItems: [], + snoozedOpen: false, + dismissedItems: [], + dismissedOpen: false, + limit: 4, + }); + expect(plan.groupRows.get("g1")).toHaveLength(3); + expect(plan.groupRows.get("g2")).toHaveLength(1); + expect(plan.hasMoreRows).toBe(true); + }); + + it("renders everything and reports no more rows when the budget covers the feed", () => { + const plan = planAttentionRenderRows({ + groups: [{ key: "g1", label: null, items: items("a", 5) }], + collapsedGroupKeys: new Set(), + snoozedItems: items("s", 2), + snoozedOpen: true, + dismissedItems: items("d", 2), + dismissedOpen: true, + limit: 9, + }); + expect(plan.groupRows.get("g1")).toHaveLength(5); + expect(plan.snoozedRows).toHaveLength(2); + expect(plan.dismissedRows).toHaveLength(2); + expect(plan.hasMoreRows).toBe(false); + }); + + it("collapsed groups and closed curtains consume no budget and never truncate", () => { + const plan = planAttentionRenderRows({ + groups: [ + { key: "g1", label: "One", items: items("a", 50) }, + { key: "g2", label: "Two", items: items("b", 2) }, + ], + collapsedGroupKeys: new Set(["g1"]), + snoozedItems: items("s", 50), + snoozedOpen: false, + dismissedItems: [], + dismissedOpen: false, + limit: 2, + }); + expect(plan.groupRows.get("g1")).toHaveLength(0); + expect(plan.groupRows.get("g2")).toHaveLength(2); + expect(plan.snoozedRows).toHaveLength(0); + expect(plan.hasMoreRows).toBe(false); + }); + + it("curtains draw from the same budget after the active groups", () => { + const plan = planAttentionRenderRows({ + groups: [{ key: "g1", label: null, items: items("a", 3) }], + collapsedGroupKeys: new Set(), + snoozedItems: items("s", 5), + snoozedOpen: true, + dismissedItems: items("d", 5), + dismissedOpen: true, + limit: 5, + }); + expect(plan.groupRows.get("g1")).toHaveLength(3); + expect(plan.snoozedRows).toHaveLength(2); + expect(plan.dismissedRows).toHaveLength(0); + expect(plan.hasMoreRows).toBe(true); + }); +}); diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index eca5e31d0f..f99665fe06 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -530,6 +530,48 @@ export function buildAttentionFilterOptions(items: AttentionItem[]): AttentionFi }; } +export interface AttentionRenderPlan { + /** Rows to render per group key (empty for collapsed groups). */ + groupRows: Map; + snoozedRows: AttentionItem[]; + dismissedRows: AttentionItem[]; + /** True when at least one visible row was left unrendered by the budget. */ + hasMoreRows: boolean; +} + +/** + * Allocate a bounded render budget across the queue in document order — active + * groups first, then the open curtains (PAP-13784). The feed is uncapped, so + * the page renders only `limit` rows and grows the budget as the user scrolls; + * collapsed groups and closed curtains cost nothing. + */ +export function planAttentionRenderRows(options: { + groups: AttentionGroup[]; + collapsedGroupKeys: ReadonlySet; + snoozedItems: AttentionItem[]; + snoozedOpen: boolean; + dismissedItems: AttentionItem[]; + dismissedOpen: boolean; + limit: number; +}): AttentionRenderPlan { + let remaining = options.limit; + let truncated = false; + const take = (items: AttentionItem[]): AttentionItem[] => { + const slice = items.slice(0, Math.max(0, remaining)); + remaining -= slice.length; + if (slice.length < items.length) truncated = true; + return slice; + }; + const groupRows = new Map(); + for (const group of options.groups) { + const collapsed = group.label !== null && options.collapsedGroupKeys.has(group.key); + groupRows.set(group.key, collapsed ? [] : take(group.items)); + } + const snoozedRows = options.snoozedOpen ? take(options.snoozedItems) : []; + const dismissedRows = options.dismissedOpen ? take(options.dismissedItems) : []; + return { groupRows, snoozedRows, dismissedRows, hasMoreRows: truncated }; +} + const DATE_BUCKET_ORDER = ["today", "yesterday", "this_week", "earlier"] as const; type DateBucket = (typeof DATE_BUCKET_ORDER)[number]; diff --git a/ui/src/pages/WhatNeedsMe.tsx b/ui/src/pages/WhatNeedsMe.tsx index 00b33070ac..2138e262cb 100644 --- a/ui/src/pages/WhatNeedsMe.tsx +++ b/ui/src/pages/WhatNeedsMe.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react"; import type { Agent, AttentionItem } from "@paperclipai/shared"; @@ -25,6 +25,7 @@ import { loadAttentionSortOrder, loadCollapsedAttentionGroupKeys, NO_GROUP_SENTINEL, + planAttentionRenderRows, saveAttentionFilters, saveAttentionGroupBy, saveAttentionSortOrder, @@ -51,6 +52,31 @@ const SEVERITY_LABELS: Record = { low: "Low", }; +/** 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; + +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 { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -165,6 +191,80 @@ export function WhatNeedsMe() { [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); @@ -207,38 +307,57 @@ export function WhatNeedsMe() { }); }; - const handleUndoDismiss = (item: AttentionItem) => { - setPendingHide((prev) => { - const next = new Set(prev); - next.delete(item.id); - return next; - }); - restore(item.dismissalKey); - }; - const handleDismiss = (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) }, - }); - }; - const handleSnooze = (item: AttentionItem, snoozedUntil: string) => { - setPendingHide((prev) => new Set(prev).add(item.id)); - snooze(item.dismissalKey, snoozedUntil); - if (expandedId === item.id) setExpandedId(null); - }; - const handleRestore = (item: AttentionItem) => { - setPendingRestore((prev) => new Set(prev).add(item.id)); - restore(item.dismissalKey); - }; + // 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); + setExpandedId((prev) => (prev === item.id ? null : item.id)); + }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -282,7 +401,7 @@ export function WhatNeedsMe() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [keyboardItems, navigate, selectedAttentionId]); + }, [handleDismiss, keyboardItems, navigate, selectedAttentionId]); const activeFilterCount = countActiveAttentionFilters(filters); if (!selectedCompanyId) { @@ -296,7 +415,7 @@ export function WhatNeedsMe() { const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0; return ( -
+

Decisions

@@ -423,16 +542,13 @@ export function WhatNeedsMe() { )} {!collapsed && (
- {group.items.map((item) => ( + {(renderPlan.groupRows.get(group.key) ?? []).map((item) => ( { - setSelectedAttentionId(item.id); - setExpandedId((prev) => (prev === item.id ? null : item.id)); - }} + onToggleExpand={handleToggleExpand} onDismiss={handleDismiss} onSnooze={handleSnooze} agentMap={agentMap} @@ -454,14 +570,14 @@ export function WhatNeedsMe() { open={snoozedOpen} onToggle={() => setSnoozedOpen((prev) => !prev)} > - {snoozedItems.map((item) => ( + {renderPlan.snoozedRows.map((item) => ( {}} + onToggleExpand={noopToggleExpand} onDismiss={handleDismiss} onRestore={handleRestore} agentMap={agentMap} @@ -478,14 +594,14 @@ export function WhatNeedsMe() { open={dismissedOpen} onToggle={() => setDismissedOpen((prev) => !prev)} > - {dismissedItems.map((item) => ( + {renderPlan.dismissedRows.map((item) => ( {}} + onToggleExpand={noopToggleExpand} onDismiss={handleDismiss} onRestore={handleRestore} agentMap={agentMap}