perf(ui): improve Decisions scrolling performance (#9468)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators use the Decisions page to review an uncapped attention feed across active, snoozed, and dismissed items > - Large feeds mounted every row eagerly, and routine interactions re-rendered the full queue > - That made initial paint and scrolling progressively slower as decision history accumulated > - This pull request bounds rendering, stabilizes row props, and lets off-screen rows skip layout and paint work > - The benefit is a responsive Decisions page even for companies with large attention histories ## Linked Issues or Issue Description ### What happened? Opening `/decisions` for a company with a large attention history eagerly mounted every visible-feed row. Expanding, selecting, dismissing, snoozing, or restoring an item could also re-render the entire queue. ### Expected behavior The page should render a bounded initial window, progressively reveal more rows near the scroll boundary, and avoid re-rendering unaffected rows during interactions. ### Steps to reproduce 1. Populate a company with hundreds of attention items. 2. Open `/decisions`. 3. Scroll and interact with individual rows. 4. Observe increasing initial render, layout, paint, and interaction cost on the previous implementation. ### Paperclip version or commit Reproduced on `master` before this PR. ### Deployment and installation Local development, built from source. This is a core UI issue, not adapter- or database-specific. ### Additional context Searched open public issues and PRs; no duplicate was found. ## What Changed - Added a pure `planAttentionRenderRows` helper that allocates one render budget across active groups and open snoozed/dismissed curtains in document order. - Render 50 rows initially and add 100 more when the Decisions page approaches the scroll boundary. - Memoized `AttentionQueueRow`, stabilized parent callbacks and inbox dismissal actions, and passed row items through a shared expand callback. - Added `content-visibility: auto` and intrinsic containment so accumulated off-screen rows avoid unnecessary layout and paint work. - Added render-plan coverage and a regression test proving identical row props do not re-render after a parent update. ## Verification - `pnpm -C ui typecheck` - `pnpm -C ui exec vitest run src/lib/attention.test.ts src/components/AttentionQueueRow.test.tsx src/components/Sidebar.test.tsx src/pages/Inbox.test.tsx` — 96 tests passed - `pnpm check:token-gates` — all gates clean ## Risks - Low risk: the change is UI-only and does not alter API or database contracts. - The main behavioral risk is incorrect row-budget accounting across collapsed groups or open curtains; the pure planner has focused tests for ordering, truncation, and collapsed/closed sections. - Progressive rendering means rows beyond the current budget are intentionally absent until scrolling nears the boundary, matching the existing Issues list pattern. > This is a targeted performance fix and does not overlap planned core feature work in `ROADMAP.md`. ## Model Used - Anthropic Claude Fable 5 assisted with the implementation using repository tools and code execution. - OpenAI Codex `gpt-5.6-sol` prepared and verified the PR with high reasoning effort, repository tools, shell execution, and GitHub/Paperclip API access. The runtime did not expose a context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no documentation changes were required for this UI-only behavior) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c8253e3641
commit
8a0db228a6
|
|
@ -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<typeof import("../lib/utils")>();
|
||||
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 (
|
||||
<AttentionQueueRow
|
||||
item={item}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
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(
|
||||
<AttentionQueueRow
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, type KeyboardEvent } from "react";
|
||||
import { memo, useState, type KeyboardEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
|
|
@ -65,7 +65,8 @@ interface AttentionQueueRowProps {
|
|||
item: AttentionItem;
|
||||
companyId: string;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => 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({
|
|||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden border border-border bg-card",
|
||||
// The feed is uncapped, so off-screen rows must not cost layout/paint
|
||||
// while scrolling. The intrinsic-size estimate only matters before a
|
||||
// row's first paint; `auto` keeps the real measured height afterwards.
|
||||
"[content-visibility:auto] [contain-intrinsic-size:auto_104px]",
|
||||
"motion-safe:transition-[opacity,transform,border-color,background-color] motion-safe:duration-200 motion-safe:ease-out hover:border-border/80",
|
||||
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
|
||||
selected && "border-ring ring-1 ring-ring",
|
||||
|
|
@ -256,7 +268,7 @@ export function AttentionQueueRow({
|
|||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col items-end gap-1" data-attention-actions="true">
|
||||
{!expanded && <CompactDecisionActions item={item} companyId={companyId} onOpen={onToggleExpand} />}
|
||||
{!expanded && <CompactDecisionActions item={item} companyId={companyId} onOpen={() => onToggleExpand(item)} />}
|
||||
|
||||
<div className="flex items-start justify-end gap-1">
|
||||
{!inline && href && (
|
||||
|
|
@ -292,7 +304,7 @@ export function AttentionQueueRow({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, AttentionItem[]>;
|
||||
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<string>;
|
||||
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<string, AttentionItem[]>();
|
||||
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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
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<HTMLDivElement | null>(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 (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div ref={rootRef} className="max-w-3xl space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-bold">Decisions</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -423,16 +542,13 @@ export function WhatNeedsMe() {
|
|||
)}
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
{(renderPlan.groupRows.get(group.key) ?? []).map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={() => {
|
||||
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) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
variant="hidden"
|
||||
expanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
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) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
variant="hidden"
|
||||
expanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onToggleExpand={noopToggleExpand}
|
||||
onDismiss={handleDismiss}
|
||||
onRestore={handleRestore}
|
||||
agentMap={agentMap}
|
||||
|
|
|
|||
Loading…
Reference in New Issue