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:
Dotta 2026-07-12 20:44:52 -05:00 committed by GitHub
parent c8253e3641
commit 8a0db228a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 344 additions and 56 deletions

View File

@ -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

View File

@ -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";

View File

@ -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,
};
}

View File

@ -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);
});
});

View File

@ -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];

View File

@ -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}