feat(decisions): add desk workflow and retention (#10672)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Decisions desk shows work that needs a human decision
> - The queue foundation can group and rank decision work
> - Operators also need a focused daily view and a safe way to handle
old work
> - This pull request adds the desk controls, the aging shelf, and
reversible retention
> - It also binds bulk archive decisions to the exact reviewed item set
> - The benefit is a smaller daily queue without lost or orphaned work

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: server, UI, database, and shared contracts.

**Problem or motivation**

Decision work can grow into one large company list. Operators need quick
queue and date controls. Old items also need a safe retention path that
does not delete work.

**Proposed solution**

Add queue and date controls to the Decisions desk. Compute the aging
shelf on the server. Archive idle items after 90 days unless an operator
keeps them. Keep archived items searchable and revivable. Notify origin
agents in one batch per sweep. Bind bulk archive proposals to a signed,
exact item manifest.

**Alternatives considered**

Client-only aging can drift across browsers and source kinds. Deleting
old rows removes audit and recovery paths. An unsigned dynamic bulk
query can archive items that the reviewer did not inspect.

**Roadmap alignment**

This work supports the Work Queues and decision-memory directions in
`ROADMAP.md`. It extends the Decisions and attention-feed foundation
from #10651. Related earlier work includes #9380, #10010, and #10474.

## What Changed

- Added the queue rail, date chips, decide split, triage strip, queue
page, and aging shelf UI.
- Added server-owned shelf state with per-queue retention overrides.
- Added reversible retention state, archive history, and an idempotent
notification outbox.
- Added the 90-day archive sweeper, Keep exemption, archived feed query,
and revive actions.
- Added one origin-agent notification per agent and sweep.
- Added signed bulk archive proposals with exact-set and version checks.
- Persisted queue-exclusion reasons atomically and kept cross-domain
source resolution per-item until it has an exact-set transaction
contract.
- Added API contracts, OpenAPI entries, migration coverage, focused
tests, and Storybook screens.

## Verification

- `pnpm -r typecheck`
- `pnpm test:run` (server: 329 files and 3,453 tests passed; UI: 408
files and 3,362 tests passed)
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts`
- `pnpm build`
- `pnpm check:token-gates`
- Focused retention, attention, decisions, migration replay, startup,
and UI API tests.
- Complete queue snapshot regression with 51 items across the normal
50-item page boundary.

The unmodified CLI test reports one warning assertion in this runtime
because the harness injects static AWS credential variables. The
isolated test passes when those two variables are removed.

## Risks

- The migration adds retention and notification outbox tables. It uses
idempotent table, index, and foreign-key creation.
- Retention runs on the heartbeat scheduler interval. Compare-and-set
version checks prevent stale archive writes.
- Bulk archive acceptance fails closed when authority, activity,
version, or the reviewed set changes.
- Archive is reversible and does not delete source records.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with `gpt-5.6-sol`. The model used tool calls, code
execution, database migration generation, and test execution. The
context-window size is not exposed in this runtime.

## 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
- [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 Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-08-02 10:47:03 -05:00
1 parent 95d33e1788
commit 0a09e4d975
46 files changed
+41814 -71

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Settings2, X } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { useParams } from "@/lib/router";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { decisionQueuesApi } from "../api/decisionQueues";
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 { cn } from "../lib/utils";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
import { DecisionQueueRail } from "../components/DecisionQueueRail";
import { Button } from "../components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
/**
* Queue page (PAP-16032 §4.1 / wireframe screen 2). A homogeneous list of one
* queue's pending decisions with per-item resolution, exclusion (with an
* optional reason), and the queue's seed-rules card with an enable/disable
* toggle. The list reuses the desk's card so a decision looks and resolves the
* same wherever it is surfaced.
*/
export function DecisionQueuePage() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const queryClient = useQueryClient();
const params = useParams<{ key: string }>();
const queueKey = params.key ?? "";
const [expandedId, setExpandedId] = useState<string | null>(null);
const { dismiss, snooze } = useInboxDismissals(selectedCompanyId);
const { data: queues } = useQuery({
queryKey: queryKeys.decisionQueues.list(selectedCompanyId!),
queryFn: () => decisionQueuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const queue = useMemo(() => queues?.find((q) => q.key === queueKey) ?? null, [queues, queueKey]);
const {
data: feed,
isLoading,
error,
} = useQuery({
queryKey: [...queryKeys.attention(selectedCompanyId!), "queue", queueKey],
queryFn: () => attentionApi.list(selectedCompanyId!, { queue: queueKey, all: true }),
enabled: !!selectedCompanyId && !!queueKey,
refetchOnWindowFocus: true,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
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<string, Agent>();
for (const agent of agents ?? []) map.set(agent.id, agent);
return map;
}, [agents]);
useEffect(() => {
setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: queue?.title ?? queueKey }]);
}, [setBreadcrumbs, queue?.title, queueKey]);
const items = useMemo(
() => (feed?.items ?? []).filter((item) => !(item.dismissal?.isActive ?? false)),
[feed],
);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) });
};
const toggleSeedRules = useMutation({
mutationFn: (enabled: boolean) =>
decisionQueuesApi.update(selectedCompanyId!, queueKey, { seedRulesEnabled: enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) }),
onError: (err) =>
pushToast({
title: "Could not update seeding",
body: err instanceof Error ? err.message : "Please try again.",
tone: "error",
}),
});
if (!selectedCompanyId) {
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
}
if (isLoading) {
return <PageSkeleton variant="approvals" />;
}
return (
<div className="max-w-3xl space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="text-xl font-bold">{queue?.title ?? queueKey}</h1>
{queue?.description && <p className="mt-0.5 text-sm text-muted-foreground">{queue.description}</p>}
</div>
</div>
<DecisionQueueRail companyId={selectedCompanyId} activeQueueKey={queueKey} />
{queue && queue.seedRules.length > 0 && (
<SeedRulesCard
enabled={queue.seedRulesEnabled}
rules={queue.seedRules.map((rule) => rule.description)}
pending={toggleSeedRules.isPending}
onToggle={() => toggleSeedRules.mutate(!queue.seedRulesEnabled)}
/>
)}
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border py-14 text-center">
<p className="text-sm font-medium text-foreground">This queue is empty.</p>
<p className="mt-1 text-xs text-muted-foreground">
Decisions land here when they match the queue's rules or an agent adds them.
</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<QueueItemRow
key={item.id}
item={item}
companyId={selectedCompanyId}
queueKey={queueKey}
agentMap={agentMap}
agents={agents}
currentUserId={currentUserId}
expanded={expandedId === item.id}
onToggleExpand={(next) => setExpandedId((prev) => (prev === next.id ? null : next.id))}
onDismiss={(next) => dismiss(next.dismissalKey)}
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
onExcluded={invalidate}
/>
))}
</div>
)}
</div>
);
}
function SeedRulesCard({
enabled,
rules,
pending,
onToggle,
}: {
enabled: boolean;
rules: string[];
pending: boolean;
onToggle: () => void;
}) {
return (
<div className="rounded-xl border border-border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<Settings2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Auto-seeding {enabled ? "on" : "off"}</p>
<ul className="mt-1 space-y-0.5">
{rules.map((rule) => (
<li key={rule} className="flex items-start gap-1.5 text-xs text-muted-foreground">
<Check className="mt-0.5 h-3 w-3 shrink-0" />
{rule}
</li>
))}
</ul>
</div>
</div>
<Button type="button" variant="outline" size="xs" className="h-7 shrink-0" disabled={pending} onClick={onToggle}>
{pending && <Loader2 className="h-3 w-3 animate-spin" />}
{enabled ? "Disable" : "Enable"}
</Button>
</div>
</div>
);
}
function QueueItemRow({
item,
companyId,
queueKey,
agentMap,
agents,
currentUserId,
expanded,
onToggleExpand,
onDismiss,
onSnooze,
onExcluded,
}: {
item: AttentionItem;
companyId: string;
queueKey: string;
agentMap: Map<string, Agent>;
agents: Agent[] | undefined;
currentUserId: string | null;
expanded: boolean;
onToggleExpand: (item: AttentionItem) => void;
onDismiss: (item: AttentionItem) => void;
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
onExcluded: () => void;
}) {
const { pushToast } = useToastActions();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const exclude = useMutation({
mutationFn: async () => {
await decisionQueuesApi.removeItem(
companyId,
queueKey,
item.sourceKind,
item.subject.id,
reason.trim() || undefined,
);
},
onSuccess: () => {
setOpen(false);
setReason("");
pushToast({ title: "Removed from queue", body: item.subject.title ?? undefined, tone: "info" });
onExcluded();
},
onError: (err) =>
pushToast({
title: "Could not exclude",
body: err instanceof Error ? err.message : "Please try again.",
tone: "error",
}),
});
return (
<div className="space-y-1.5">
<div className="flex items-center justify-end px-1">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="xs" className="h-7 gap-1 text-muted-foreground">
<X className="h-3.5 w-3.5" />
Exclude
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 space-y-2 p-3">
<p className="text-xs font-medium text-foreground">Remove from this queue</p>
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Reason (optional)…"
className="min-h-16 w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
/>
<div className="flex justify-end gap-1">
<Button type="button" variant="ghost" size="xs" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="button"
size="xs"
className={cn(exclude.isPending && "opacity-80")}
disabled={exclude.isPending}
onClick={() => exclude.mutate()}
>
{exclude.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
Exclude
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<AttentionQueueRow
item={item}
companyId={companyId}
expanded={expanded}
onToggleExpand={onToggleExpand}
onDismiss={onDismiss}
onSnooze={onSnooze}
agentMap={agentMap}
agents={agents}
showTriage
currentUserId={currentUserId}
/>
</div>
);
}
+260 -16
View File
@@ -1,8 +1,8 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpDown, Check, CheckCircle2, GraduationCap, Inbox, Layers, ListFilter } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowUpDown, Check, CheckCircle2, GraduationCap, Inbox, Layers, ListFilter, Loader2, Sun } from "lucide-react";
import type { Agent, AttentionItem, AttentionSubject } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useNavigate, useSearchParams } from "@/lib/router";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -13,8 +13,12 @@ import { useToastActions } from "../context/ToastContext";
import { useInboxDismissals } from "../hooks/useInboxBadge";
import { queryKeys } from "../lib/queryKeys";
import {
ATTENTION_AGING_DAYS,
ATTENTION_GROUP_BY_OPTIONS,
ATTENTION_SORT_OPTIONS,
attentionDecideOrder,
attentionIdleDays,
attentionIsAging,
buildAttentionFilterOptions,
countActiveAttentionFilters,
defaultAttentionFilterState,
@@ -26,22 +30,29 @@ import {
loadAttentionSortOrder,
loadCollapsedAttentionGroupKeys,
NO_GROUP_SENTINEL,
partitionDecideNow,
planAttentionRenderRows,
resolveAttentionDateRange,
saveAttentionFilters,
saveAttentionGroupBy,
saveAttentionSortOrder,
saveCollapsedAttentionGroupKeys,
sortAttentionItems,
sourceMeta,
type AttentionDateRangeId,
type AttentionFilterState,
type AttentionGroup,
type AttentionGroupBy,
type AttentionSortOrder,
} from "../lib/attention";
import { decisionQueuesApi } from "../api/decisionQueues";
import { decisionTrainingHref } from "../lib/decisionTraining";
import { cn } from "../lib/utils";
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
import { DecisionQueueRail } from "../components/DecisionQueueRail";
import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips";
import { DecisionResolver } from "../components/DecisionResolver";
import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
@@ -116,9 +127,19 @@ export function WhatNeedsMe() {
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => 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<AttentionDateRangeId>("all");
const [customRange, setCustomRange] = useState<AttentionCustomRange>({ 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<Set<string>>(() => new Set());
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
@@ -126,6 +147,15 @@ export function WhatNeedsMe() {
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
const { pushToast } = useToastActions();
const navigate = useNavigate();
const queryClient = useQueryClient();
// 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" }]);
@@ -145,8 +175,13 @@ export function WhatNeedsMe() {
// 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"],
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true }),
queryKey: [
...queryKeys.attention(selectedCompanyId!),
"with-dismissed",
activityBounds.activitySince ?? null,
activityBounds.activityUntil ?? null,
],
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true, ...activityBounds }),
enabled: !!selectedCompanyId,
refetchOnWindowFocus: true,
});
@@ -199,6 +234,19 @@ export function WhatNeedsMe() {
),
[allItems, pendingHide, pendingRestore],
);
// The server's clock at feed time — used for the decide-by split and the aging
// idle labels so they match `decideNowCount` 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(
@@ -216,14 +264,37 @@ export function WhatNeedsMe() {
[allItems, pendingRestore],
);
const filterOptions = useMemo(() => buildAttentionFilterOptions(activeItems), [activeItems]);
const filterOptions = useMemo(() => buildAttentionFilterOptions(deskItems), [deskItems]);
// Filter → sort → group, all client-side so switching re-buckets without a refetch.
const groups = useMemo(() => {
const filtered = filterAttentionItems(activeItems, filters);
// Filter → sort → group, all client-side so switching re-buckets without a
// refetch. In the default (ungrouped) view the desk splits into the two §4.3
// shelves — "Decide now" (due today / overdue) and "Can wait" — ordered by
// decide-by; any explicit group-by keeps the Inbox-style activity grouping.
const groups = useMemo<AttentionGroup[]>(() => {
const filtered = filterAttentionItems(deskItems, filters);
if (groupBy === "none") {
const ordered = [...filtered].sort((a, b) => {
const [aBucket, aDeadline] = attentionDecideOrder(a, now);
const [bBucket, bDeadline] = attentionDecideOrder(b, now);
if (aBucket !== bBucket) return aBucket - bBucket;
if (aDeadline !== bDeadline) return aDeadline - bDeadline;
return a.rank - b.rank;
});
const { decideNow, canWait } = partitionDecideNow(ordered, now);
const shelves: AttentionGroup[] = [];
if (decideNow.length > 0) shelves.push({ key: "desk:decide-now", label: "Decide now", items: decideNow });
if (canWait.length > 0) shelves.push({ key: "desk:can-wait", label: "Can wait", items: canWait });
return shelves;
}
const sorted = sortAttentionItems(filtered, sortOrder);
return groupAttentionItems(sorted, groupBy);
}, [activeItems, filters, sortOrder, groupBy]);
}, [deskItems, filters, sortOrder, groupBy, now]);
// "Today is clear" — the desk has decisions but none are due today.
const deskClearToday = useMemo(
() => groupBy === "none" && deskItems.length > 0 && !groups.some((group) => group.key === "desk:decide-now"),
[groupBy, deskItems, groups],
);
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
const keyboardItems = useMemo(
@@ -317,14 +388,39 @@ export function WhatNeedsMe() {
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 || activeItems.length === 0) return;
const sorted = sortAttentionItems(activeItems, sortOrder);
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);
}, [activeItems, autoExpandDone, sortOrder]);
}, [deskItems, autoExpandDone, sortOrder]);
const updateGroupBy = (next: AttentionGroupBy) => {
setGroupBy(next);
@@ -572,6 +668,20 @@ export function WhatNeedsMe() {
</div>
</div>
{/* 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. */}
<div className="space-y-2">
<DecisionQueueRail companyId={selectedCompanyId} activeQueueKey={null} />
<DecisionDateChips
value={dateRange}
custom={customRange}
onChange={(value, custom) => {
setDateRange(value);
setCustomRange(custom);
}}
/>
</div>
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
{!hasAnything ? (
@@ -579,9 +689,11 @@ export function WhatNeedsMe() {
) : (
<div className="space-y-4">
{visibleCount === 0 ? (
<CaughtUpNote filtered={activeItems.length > 0} />
<CaughtUpNote filtered={deskItems.length > 0} />
) : (
groups.map((group) => {
<>
{deskClearToday && <TodayClearNote />}
{groups.map((group) => {
const groupLabel = group.label;
const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key);
return (
@@ -638,6 +750,8 @@ export function WhatNeedsMe() {
onSnooze={handleSnooze}
onTrain={handleTrain}
agentMap={agentMap}
agents={agents}
showTriage
currentUserId={currentUserId}
selected={selectionFromKeyboard && selectedAttentionId === item.id}
/>
@@ -650,7 +764,8 @@ export function WhatNeedsMe() {
)}
</section>
);
})
})}
</>
)}
{snoozedItems.length > 0 && (
@@ -701,6 +816,35 @@ export function WhatNeedsMe() {
</Curtain>
)}
{agingItems.length > 0 && (
<Curtain
label="Aging"
count={agingItems.length}
open={agingOpen}
onToggle={() => setAgingOpen((prev) => !prev)}
>
<p className="text-xs text-muted-foreground">
Idle past {ATTENTION_AGING_DAYS} days — kept off the desk. Keep any you still want surfaced.
</p>
{agingItems.map((item) => (
<AgingItemRow
key={item.id}
item={item}
companyId={selectedCompanyId}
now={now}
agentMap={agentMap}
agents={agents}
currentUserId={currentUserId}
expanded={expandedId === item.id}
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onTrain={handleTrain}
/>
))}
</Curtain>
)}
</div>
)}
@@ -964,6 +1108,106 @@ function Curtain({
);
}
/**
* Slim banner shown at the top of the desk when there are decisions but none
* are due today — the "Decide now" shelf is empty, so we say so rather than
* leading with a bare "Can wait" header (§4.3 "empty state when today is clear").
*/
function TodayClearNote() {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3">
<Sun className="h-4 w-4 shrink-0 text-green-500" />
<p className="text-sm text-foreground">
Nothing needs a decision <span className="font-medium">today</span>. Everything below can wait.
</p>
</div>
);
}
/**
* An aging-shelf row (§4.4): the standard card, prefaced by an idle-duration
* label and a "Keep on desk" affordance that clears the shelf flag server-side
* (P1 retention `keep`). Archival/sweeper mechanics are P5 — this is the split
* plus the Keep stub only.
*/
function AgingItemRow({
item,
companyId,
now,
agentMap,
agents,
currentUserId,
expanded,
onToggleExpand,
onDismiss,
onSnooze,
onTrain,
}: {
item: AttentionItem;
companyId: string;
now: number;
agentMap: Map<string, Agent>;
agents: Agent[] | undefined;
currentUserId: string | null;
expanded: boolean;
onToggleExpand: (item: AttentionItem) => void;
onDismiss: (item: AttentionItem) => void;
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
onTrain: (item: AttentionItem) => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const idleDays = attentionIdleDays(item, now);
const keep = useMutation({
mutationFn: () => decisionQueuesApi.setKeep(companyId, item.sourceKind, item.subject.id, true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
pushToast({ title: "Kept on desk", body: item.subject.title ?? undefined, tone: "success" });
},
onError: (error) =>
pushToast({
title: "Could not keep this decision",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 px-1">
<span className="text-(length:--text-nano) text-muted-foreground">
Idle {idleDays} {idleDays === 1 ? "day" : "days"}
</span>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 gap-1"
disabled={keep.isPending || item.keep}
onClick={() => keep.mutate()}
>
{keep.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
<Sun className="h-3.5 w-3.5" />
{item.keep ? "Kept" : "Keep on desk"}
</Button>
</div>
<AttentionQueueRow
item={item}
companyId={companyId}
expanded={expanded}
onToggleExpand={onToggleExpand}
onDismiss={onDismiss}
onSnooze={onSnooze}
onTrain={onTrain}
agentMap={agentMap}
agents={agents}
showTriage
currentUserId={currentUserId}
/>
</div>
);
}
function CaughtUpNote({ filtered }: { filtered: boolean }) {
return (
<div className="rounded-xl border border-dashed border-border py-10 text-center">