mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
PAP-13408 T2: Inbox-style attention toolbar, date grouping, curtains
Rework the What-needs-me queue around the approved PAP-13401 plan (§1, §2, curtains from §6, left-align from §9): - lib/attention.ts: date-first grouping (Today/Yesterday/This week/Earlier), Type/Project/Severity group-by, newest/oldest sort, and type/project/ workspace/severity filters — all pure functions with localStorage helpers mirroring the InboxWorkItemGroupBy pattern. Re-buckets client-side (no refetch). - WhatNeedsMe.tsx: Inbox-language toolbar (ListFilter / Layers / ArrowUpDown popovers), collapsible group sections via the shared IssueGroupHeader with counts, and Snoozed(n)/Dismissed(n) curtains listing hidden rows (fetched with includeDismissed=true) with Restore + snooze reappear time. Fetches under a distinct query key so dismissed rows never inflate the sidebar badge. Left-aligned container (dropped mx-auto). - AttentionQueueRow.tsx: snooze presets submenu + dismiss on active rows; "hidden" variant renders curtain rows with Restore and reappear time. - inboxDismissals API + useInboxDismissals hook: add snooze + restore, and invalidate the attention feed on any dismiss/snooze/restore. - Tests: 20+ new cases covering sort/date-bucket/group/filter/options. Backfilled fixtures for T1's new required fields (dismissal, project, workspace, detail, InboxDismissal.kind/snoozedUntil). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
9dcdb3ce60
commit
5b98e17c4f
9 files changed
+1201
-75
No files matched your search
@@ -5,4 +5,12 @@ export const inboxDismissalsApi = {
|
||||
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
|
||||
dismiss: (companyId: string, itemKey: string) =>
|
||||
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
|
||||
snooze: (companyId: string, itemKey: string, snoozedUntil: string) =>
|
||||
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, {
|
||||
itemKey,
|
||||
kind: "snooze",
|
||||
snoozedUntil,
|
||||
}),
|
||||
restore: (companyId: string, itemKey: string) =>
|
||||
api.delete<void>(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`),
|
||||
};
|
||||
@@ -74,6 +74,10 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
createdAt: "2026-07-09T12:00:00Z",
|
||||
updatedAt: "2026-07-09T12:00:00Z",
|
||||
relatedIssue: null,
|
||||
project: null,
|
||||
workspace: null,
|
||||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
@@ -21,16 +24,33 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
||||
|
||||
/** Snooze presets, resolved to a future ISO timestamp at click time. */
|
||||
const SNOOZE_PRESETS: ReadonlyArray<{ label: string; ms: number }> = [
|
||||
{ label: "1 hour", ms: 60 * 60 * 1000 },
|
||||
{ label: "3 hours", ms: 3 * 60 * 60 * 1000 },
|
||||
{ label: "Tomorrow", ms: 24 * 60 * 60 * 1000 },
|
||||
{ label: "Next week", ms: 7 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
interface AttentionQueueRowProps {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onDismiss: (item: AttentionItem) => void;
|
||||
onSnooze?: (item: AttentionItem, snoozedUntil: string) => void;
|
||||
/** Restore a snoozed/dismissed row (curtain variant only). */
|
||||
onRestore?: (item: AttentionItem) => void;
|
||||
/** "active" renders the live queue row; "hidden" renders a curtain row. */
|
||||
variant?: "active" | "hidden";
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
@@ -42,6 +62,9 @@ export function AttentionQueueRow({
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onDismiss,
|
||||
onSnooze,
|
||||
onRestore,
|
||||
variant = "active",
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
@@ -49,14 +72,17 @@ export function AttentionQueueRow({
|
||||
const meta = sourceMeta(item.sourceKind);
|
||||
const severity = severityStyle(item.severity);
|
||||
const Icon = meta.icon;
|
||||
const inline = isInlineResolvable(item);
|
||||
const isHidden = variant === "hidden";
|
||||
const inline = !isHidden && isInlineResolvable(item);
|
||||
const href = item.subject.href;
|
||||
const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden rounded-lg border border-border bg-card",
|
||||
"transition-colors hover:border-border/80",
|
||||
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
|
||||
)}
|
||||
data-attention-source={item.sourceKind}
|
||||
data-attention-severity={item.severity}
|
||||
@@ -97,10 +123,20 @@ export function AttentionQueueRow({
|
||||
{item.relatedIssue.identifier}
|
||||
</Link>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{relativeTime(item.activityAt)}
|
||||
</span>
|
||||
{isHidden && snoozedUntil ? (
|
||||
<span
|
||||
className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground"
|
||||
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
|
||||
>
|
||||
<AlarmClock className="h-3 w-3" />
|
||||
Reappears {reappearLabel(snoozedUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{relativeTime(item.activityAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
@@ -131,27 +167,67 @@ export function AttentionQueueRow({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Snooze / dismiss */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{isHidden ? (
|
||||
/* Curtain rows: restore back into the live queue. */
|
||||
onRestore && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onRestore(item)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>Snooze / dismiss</DropdownMenuItem>
|
||||
{href && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
)
|
||||
) : (
|
||||
/* Active rows: snooze presets + dismiss. */
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSnooze && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<AlarmClock className="h-4 w-4" />
|
||||
Snooze
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.label}
|
||||
onClick={() => onSnooze(item, new Date(Date.now() + preset.ms).toISOString())}
|
||||
>
|
||||
{preset.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{href && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,6 +247,18 @@ export function AttentionQueueRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */
|
||||
function reappearLabel(snoozedUntil: string): string {
|
||||
const diffMs = new Date(snoozedUntil).getTime() - Date.now();
|
||||
if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
|
||||
const diffMin = Math.round(diffMs / 60000);
|
||||
if (diffMin < 60) return `in ${diffMin}m`;
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `in ${diffHr}h`;
|
||||
const diffDay = Math.round(diffHr / 24);
|
||||
return `in ${diffDay}d`;
|
||||
}
|
||||
|
||||
function InlineResolver({
|
||||
item,
|
||||
companyId,
|
||||
|
||||
@@ -89,11 +89,30 @@ export function useInboxDismissals(companyId: string | null | undefined) {
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!companyId) return;
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
invalidateDismissalConsumers();
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateDismissalConsumers() {
|
||||
if (!companyId) return;
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
// The attention feed derives its rows from server-side dismissals, so any
|
||||
// dismiss/snooze/restore must re-pull it to keep the queue and curtains in sync.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
}
|
||||
|
||||
const snoozeMutation = useMutation({
|
||||
mutationFn: ({ itemKey, snoozedUntil }: { itemKey: string; snoozedUntil: string }) =>
|
||||
inboxDismissalsApi.snooze(companyId!, itemKey, snoozedUntil),
|
||||
onSettled: invalidateDismissalConsumers,
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.restore(companyId!, itemKey),
|
||||
onSettled: invalidateDismissalConsumers,
|
||||
});
|
||||
|
||||
const dismissedAtByKey = useMemo(
|
||||
() => buildInboxDismissedAtByKey(dismissals),
|
||||
[dismissals],
|
||||
@@ -103,7 +122,9 @@ export function useInboxDismissals(companyId: string | null | undefined) {
|
||||
dismissals,
|
||||
dismissedAtByKey,
|
||||
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
||||
isPending: dismissMutation.isPending,
|
||||
snooze: (itemKey: string, snoozedUntil: string) => snoozeMutation.mutate({ itemKey, snoozedUntil }),
|
||||
restore: (itemKey: string) => restoreMutation.mutate({ itemKey }),
|
||||
isPending: dismissMutation.isPending || snoozeMutation.isPending || restoreMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { attentionBadgeCount, isInlineResolvable, severityStyle, sourceMeta } from "./attention";
|
||||
import {
|
||||
attentionBadgeCount,
|
||||
attentionDateBucket,
|
||||
buildAttentionFilterOptions,
|
||||
countActiveAttentionFilters,
|
||||
defaultAttentionFilterState,
|
||||
filterAttentionItems,
|
||||
groupAttentionItems,
|
||||
isInlineResolvable,
|
||||
NO_GROUP_SENTINEL,
|
||||
severityStyle,
|
||||
sortAttentionItems,
|
||||
sourceMeta,
|
||||
} from "./attention";
|
||||
|
||||
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
return {
|
||||
@@ -21,6 +34,10 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
createdAt: "2026-07-09T12:00:00Z",
|
||||
updatedAt: "2026-07-09T12:00:00Z",
|
||||
relatedIssue: null,
|
||||
project: null,
|
||||
workspace: null,
|
||||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -89,3 +106,160 @@ describe("sourceMeta + severityStyle", () => {
|
||||
expect(severityStyle("critical").accent).not.toBe(severityStyle("low").accent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortAttentionItems", () => {
|
||||
const older = buildItem({ id: "old", activityAt: "2026-07-01T00:00:00Z", rank: 5 });
|
||||
const newer = buildItem({ id: "new", activityAt: "2026-07-09T00:00:00Z", rank: 9 });
|
||||
|
||||
it("puts newest first by default", () => {
|
||||
expect(sortAttentionItems([older, newer], "newest").map((i) => i.id)).toEqual(["new", "old"]);
|
||||
});
|
||||
|
||||
it("reverses to oldest first", () => {
|
||||
expect(sortAttentionItems([older, newer], "oldest").map((i) => i.id)).toEqual(["old", "new"]);
|
||||
});
|
||||
|
||||
it("breaks activity ties by rank (lower rank wins) regardless of order", () => {
|
||||
const a = buildItem({ id: "a", activityAt: "2026-07-09T00:00:00Z", rank: 2 });
|
||||
const b = buildItem({ id: "b", activityAt: "2026-07-09T00:00:00Z", rank: 1 });
|
||||
expect(sortAttentionItems([a, b], "newest").map((i) => i.id)).toEqual(["b", "a"]);
|
||||
expect(sortAttentionItems([a, b], "oldest").map((i) => i.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = [older, newer];
|
||||
sortAttentionItems(input, "newest");
|
||||
expect(input.map((i) => i.id)).toEqual(["old", "new"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attentionDateBucket", () => {
|
||||
const now = new Date("2026-07-10T12:00:00Z").getTime();
|
||||
|
||||
it("buckets by rolling calendar-day windows relative to now", () => {
|
||||
expect(attentionDateBucket("2026-07-10T09:00:00Z", now)).toBe("today");
|
||||
expect(attentionDateBucket("2026-07-09T23:00:00Z", now)).toBe("yesterday");
|
||||
expect(attentionDateBucket("2026-07-06T09:00:00Z", now)).toBe("this_week");
|
||||
expect(attentionDateBucket("2026-06-01T09:00:00Z", now)).toBe("earlier");
|
||||
});
|
||||
|
||||
it("treats invalid timestamps as earlier", () => {
|
||||
expect(attentionDateBucket("not-a-date", now)).toBe("earlier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAttentionItems", () => {
|
||||
const now = new Date("2026-07-10T12:00:00Z").getTime();
|
||||
|
||||
it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => {
|
||||
const items = [
|
||||
buildItem({ id: "earlier", activityAt: "2026-06-01T00:00:00Z" }),
|
||||
buildItem({ id: "today", activityAt: "2026-07-10T08:00:00Z" }),
|
||||
buildItem({ id: "yesterday", activityAt: "2026-07-09T08:00:00Z" }),
|
||||
];
|
||||
const groups = groupAttentionItems(items, "date", { now });
|
||||
expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday", "Earlier"]);
|
||||
expect(groups.map((g) => g.key)).toEqual(["date:today", "date:yesterday", "date:earlier"]);
|
||||
});
|
||||
|
||||
it("groups by severity in escalation order regardless of input order", () => {
|
||||
const items = [
|
||||
buildItem({ id: "low", severity: "low" }),
|
||||
buildItem({ id: "crit", severity: "critical" }),
|
||||
buildItem({ id: "med", severity: "medium" }),
|
||||
];
|
||||
const groups = groupAttentionItems(items, "severity");
|
||||
expect(groups.map((g) => g.label)).toEqual(["Critical", "Medium", "Low"]);
|
||||
});
|
||||
|
||||
it("groups by project, keeping a 'No project' bucket for unassigned rows", () => {
|
||||
const items = [
|
||||
buildItem({ id: "p1", activityAt: "2026-07-10T10:00:00Z", project: { id: "proj-1", name: "Alpha", urlKey: "alpha" } }),
|
||||
buildItem({ id: "none", activityAt: "2026-07-10T11:00:00Z", project: null }),
|
||||
];
|
||||
const groups = groupAttentionItems(items, "project");
|
||||
const noneGroup = groups.find((g) => g.key === `project:${NO_GROUP_SENTINEL}`);
|
||||
expect(noneGroup?.label).toBe("No project");
|
||||
expect(groups.find((g) => g.key === "project:proj-1")?.label).toBe("Alpha");
|
||||
// Freshest group floats first (No project row is newer).
|
||||
expect(groups[0].key).toBe(`project:${NO_GROUP_SENTINEL}`);
|
||||
});
|
||||
|
||||
it("groups by type using source labels", () => {
|
||||
const items = [
|
||||
buildItem({ id: "a", sourceKind: "approval" }),
|
||||
buildItem({ id: "j", sourceKind: "join_request" }),
|
||||
];
|
||||
const groups = groupAttentionItems(items, "type");
|
||||
expect(groups.map((g) => g.key).sort()).toEqual(["type:approval", "type:join_request"]);
|
||||
});
|
||||
|
||||
it("preserves the caller-provided intra-group order (sort governs within a bucket)", () => {
|
||||
const items = sortAttentionItems(
|
||||
[
|
||||
buildItem({ id: "t1", activityAt: "2026-07-10T08:00:00Z" }),
|
||||
buildItem({ id: "t2", activityAt: "2026-07-10T10:00:00Z" }),
|
||||
],
|
||||
"newest",
|
||||
);
|
||||
const [today] = groupAttentionItems(items, "date", { now });
|
||||
expect(today.items.map((i) => i.id)).toEqual(["t2", "t1"]);
|
||||
});
|
||||
|
||||
it("returns no groups for an empty list", () => {
|
||||
expect(groupAttentionItems([], "date", { now })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAttentionItems", () => {
|
||||
const approval = buildItem({ id: "ap", sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a" } });
|
||||
const join = buildItem({ id: "jn", sourceKind: "join_request", severity: "low", project: null });
|
||||
const items = [approval, join];
|
||||
|
||||
it("returns everything when no filters are active", () => {
|
||||
expect(filterAttentionItems(items, defaultAttentionFilterState)).toHaveLength(2);
|
||||
expect(countActiveAttentionFilters(defaultAttentionFilterState)).toBe(0);
|
||||
});
|
||||
|
||||
it("filters by source kind", () => {
|
||||
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, sourceKinds: ["approval"] });
|
||||
expect(result.map((i) => i.id)).toEqual(["ap"]);
|
||||
});
|
||||
|
||||
it("filters by severity", () => {
|
||||
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, severities: ["low"] });
|
||||
expect(result.map((i) => i.id)).toEqual(["jn"]);
|
||||
});
|
||||
|
||||
it("filters by project id and the no-project sentinel", () => {
|
||||
expect(filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: ["p1"] }).map((i) => i.id)).toEqual(["ap"]);
|
||||
expect(
|
||||
filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: [NO_GROUP_SENTINEL] }).map((i) => i.id),
|
||||
).toEqual(["jn"]);
|
||||
});
|
||||
|
||||
it("ANDs across dimensions", () => {
|
||||
const result = filterAttentionItems(items, {
|
||||
...defaultAttentionFilterState,
|
||||
sourceKinds: ["approval"],
|
||||
severities: ["low"],
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAttentionFilterOptions", () => {
|
||||
it("collects the distinct dimensions present in the feed", () => {
|
||||
const items = [
|
||||
buildItem({ sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a" }, workspace: { id: "w1", name: "WS" } }),
|
||||
buildItem({ sourceKind: "join_request", severity: "low", project: null, workspace: null }),
|
||||
];
|
||||
const options = buildAttentionFilterOptions(items);
|
||||
expect(options.sourceKinds.sort()).toEqual(["approval", "join_request"]);
|
||||
expect(options.severities).toEqual(["high", "low"]);
|
||||
expect(options.projects.map((p) => p.id)).toEqual(["p1"]);
|
||||
expect(options.workspaces.map((w) => w.id)).toEqual(["w1"]);
|
||||
expect(options.hasNoProject).toBe(true);
|
||||
expect(options.hasNoWorkspace).toBe(true);
|
||||
});
|
||||
});
|
||||
+370
-1
@@ -11,7 +11,14 @@ import {
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { AttentionFeed, AttentionItem, AttentionSeverity, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import type {
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionProjectRef,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionWorkspaceRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Source kinds the queue can fully resolve in-row. Everything else deep-links
|
||||
@@ -78,3 +85,365 @@ export function severityStyle(severity: AttentionSeverity): SeverityStyle {
|
||||
export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number {
|
||||
return feed?.items.length ?? 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping / sorting / filtering (PAP-13408 — Inbox-style toolbar)
|
||||
//
|
||||
// The queue defaults to a date-first grouping (Today / Yesterday / This week /
|
||||
// Earlier) sorted by `activityAt` desc, mirroring the `InboxWorkItemGroupBy`
|
||||
// pattern in `lib/inbox.ts`. All of these are pure functions so the page can
|
||||
// re-bucket on the client without refetching, and so the logic is unit-tested
|
||||
// independently of React.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AttentionGroupBy = "date" | "type" | "project" | "severity";
|
||||
export type AttentionSortOrder = "newest" | "oldest";
|
||||
|
||||
/** Ordered list used to render the group-by picker (label + value). */
|
||||
export const ATTENTION_GROUP_BY_OPTIONS: ReadonlyArray<[AttentionGroupBy, string]> = [
|
||||
["date", "Date"],
|
||||
["type", "Type"],
|
||||
["project", "Project"],
|
||||
["severity", "Severity"],
|
||||
];
|
||||
|
||||
export const ATTENTION_SORT_OPTIONS: ReadonlyArray<[AttentionSortOrder, string]> = [
|
||||
["newest", "Newest first"],
|
||||
["oldest", "Oldest first"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter selections. Empty arrays mean "no filter" (show everything). The
|
||||
* `__none__` sentinel represents rows with no project / workspace.
|
||||
*/
|
||||
export interface AttentionFilterState {
|
||||
sourceKinds: AttentionSourceKind[];
|
||||
projectIds: string[];
|
||||
workspaceIds: string[];
|
||||
severities: AttentionSeverity[];
|
||||
}
|
||||
|
||||
export const NO_GROUP_SENTINEL = "__none__";
|
||||
|
||||
export const defaultAttentionFilterState: AttentionFilterState = {
|
||||
sourceKinds: [],
|
||||
projectIds: [],
|
||||
workspaceIds: [],
|
||||
severities: [],
|
||||
};
|
||||
|
||||
export interface AttentionGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
items: AttentionItem[];
|
||||
}
|
||||
|
||||
export interface AttentionFilterOptions {
|
||||
sourceKinds: AttentionSourceKind[];
|
||||
projects: AttentionProjectRef[];
|
||||
workspaces: AttentionWorkspaceRef[];
|
||||
severities: AttentionSeverity[];
|
||||
/** True when at least one row has no project (adds a "No project" option). */
|
||||
hasNoProject: boolean;
|
||||
/** True when at least one row has no workspace. */
|
||||
hasNoWorkspace: boolean;
|
||||
}
|
||||
|
||||
export const ATTENTION_GROUP_BY_KEY = "paperclip:attention:group-by";
|
||||
export const ATTENTION_SORT_KEY = "paperclip:attention:sort";
|
||||
export const ATTENTION_FILTERS_KEY_PREFIX = "paperclip:attention:filters";
|
||||
export const ATTENTION_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:attention:collapsed-groups";
|
||||
|
||||
function isAttentionGroupBy(value: unknown): value is AttentionGroupBy {
|
||||
return value === "date" || value === "type" || value === "project" || value === "severity";
|
||||
}
|
||||
|
||||
export function loadAttentionGroupBy(): AttentionGroupBy {
|
||||
try {
|
||||
const raw = localStorage.getItem(ATTENTION_GROUP_BY_KEY);
|
||||
return isAttentionGroupBy(raw) ? raw : "date";
|
||||
} catch {
|
||||
return "date";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAttentionGroupBy(groupBy: AttentionGroupBy) {
|
||||
try {
|
||||
localStorage.setItem(ATTENTION_GROUP_BY_KEY, groupBy);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAttentionSortOrder(): AttentionSortOrder {
|
||||
try {
|
||||
const raw = localStorage.getItem(ATTENTION_SORT_KEY);
|
||||
return raw === "oldest" ? "oldest" : "newest";
|
||||
} catch {
|
||||
return "newest";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAttentionSortOrder(order: AttentionSortOrder) {
|
||||
try {
|
||||
localStorage.setItem(ATTENTION_SORT_KEY, order);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function getAttentionFiltersStorageKey(companyId: string | null | undefined): string | null {
|
||||
if (!companyId) return null;
|
||||
return `${ATTENTION_FILTERS_KEY_PREFIX}:${companyId}`;
|
||||
}
|
||||
|
||||
function getAttentionCollapsedGroupsStorageKey(companyId: string | null | undefined): string | null {
|
||||
if (!companyId) return null;
|
||||
return `${ATTENTION_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
const ALL_SEVERITIES: AttentionSeverity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
export function loadAttentionFilters(companyId: string | null | undefined): AttentionFilterState {
|
||||
const storageKey = getAttentionFiltersStorageKey(companyId);
|
||||
if (!storageKey) return { ...defaultAttentionFilterState };
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return { ...defaultAttentionFilterState };
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
sourceKinds: normalizeStringArray(parsed.sourceKinds) as AttentionSourceKind[],
|
||||
projectIds: normalizeStringArray(parsed.projectIds),
|
||||
workspaceIds: normalizeStringArray(parsed.workspaceIds),
|
||||
severities: normalizeStringArray(parsed.severities).filter((s): s is AttentionSeverity =>
|
||||
(ALL_SEVERITIES as string[]).includes(s),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return { ...defaultAttentionFilterState };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAttentionFilters(
|
||||
companyId: string | null | undefined,
|
||||
filters: AttentionFilterState,
|
||||
) {
|
||||
const storageKey = getAttentionFiltersStorageKey(companyId);
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(filters));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCollapsedAttentionGroupKeys(companyId: string | null | undefined): Set<string> {
|
||||
const storageKey = getAttentionCollapsedGroupsStorageKey(companyId);
|
||||
if (!storageKey) return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((e): e is string => typeof e === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCollapsedAttentionGroupKeys(
|
||||
companyId: string | null | undefined,
|
||||
groupKeys: ReadonlySet<string>,
|
||||
) {
|
||||
const storageKey = getAttentionCollapsedGroupsStorageKey(companyId);
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify([...groupKeys]));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function countActiveAttentionFilters(filters: AttentionFilterState): number {
|
||||
return (
|
||||
filters.sourceKinds.length +
|
||||
filters.projectIds.length +
|
||||
filters.workspaceIds.length +
|
||||
filters.severities.length
|
||||
);
|
||||
}
|
||||
|
||||
function attentionActivityTimestamp(item: AttentionItem): number {
|
||||
const ts = new Date(item.activityAt).getTime();
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by activity time in the requested direction. `rank` is the stable
|
||||
* tiebreaker (lower rank = higher priority) so equal-timestamp rows keep the
|
||||
* server's escalation order.
|
||||
*/
|
||||
export function sortAttentionItems(items: AttentionItem[], order: AttentionSortOrder): AttentionItem[] {
|
||||
const sign = order === "oldest" ? -1 : 1;
|
||||
return [...items].sort((a, b) => {
|
||||
const diff = attentionActivityTimestamp(b) - attentionActivityTimestamp(a);
|
||||
if (diff !== 0) return sign * diff;
|
||||
return a.rank - b.rank;
|
||||
});
|
||||
}
|
||||
|
||||
export function attentionItemMatchesFilters(item: AttentionItem, filters: AttentionFilterState): boolean {
|
||||
if (filters.sourceKinds.length > 0 && !filters.sourceKinds.includes(item.sourceKind)) return false;
|
||||
if (filters.severities.length > 0 && !filters.severities.includes(item.severity)) return false;
|
||||
if (filters.projectIds.length > 0) {
|
||||
const projectId = item.project?.id ?? NO_GROUP_SENTINEL;
|
||||
if (!filters.projectIds.includes(projectId)) return false;
|
||||
}
|
||||
if (filters.workspaceIds.length > 0) {
|
||||
const workspaceId = item.workspace?.id ?? NO_GROUP_SENTINEL;
|
||||
if (!filters.workspaceIds.includes(workspaceId)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function filterAttentionItems(items: AttentionItem[], filters: AttentionFilterState): AttentionItem[] {
|
||||
if (countActiveAttentionFilters(filters) === 0) return items;
|
||||
return items.filter((item) => attentionItemMatchesFilters(item, filters));
|
||||
}
|
||||
|
||||
/** Distinct filterable dimensions present in the current feed, for the picker. */
|
||||
export function buildAttentionFilterOptions(items: AttentionItem[]): AttentionFilterOptions {
|
||||
const sourceKinds = new Set<AttentionSourceKind>();
|
||||
const projects = new Map<string, AttentionProjectRef>();
|
||||
const workspaces = new Map<string, AttentionWorkspaceRef>();
|
||||
const severities = new Set<AttentionSeverity>();
|
||||
let hasNoProject = false;
|
||||
let hasNoWorkspace = false;
|
||||
|
||||
for (const item of items) {
|
||||
sourceKinds.add(item.sourceKind);
|
||||
severities.add(item.severity);
|
||||
if (item.project) projects.set(item.project.id, item.project);
|
||||
else hasNoProject = true;
|
||||
if (item.workspace) workspaces.set(item.workspace.id, item.workspace);
|
||||
else hasNoWorkspace = true;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceKinds: [...sourceKinds].sort((a, b) => sourceMeta(a).label.localeCompare(sourceMeta(b).label)),
|
||||
projects: [...projects.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
workspaces: [...workspaces.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
severities: ALL_SEVERITIES.filter((s) => severities.has(s)),
|
||||
hasNoProject,
|
||||
hasNoWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
const DATE_BUCKET_ORDER = ["today", "yesterday", "this_week", "earlier"] as const;
|
||||
type DateBucket = (typeof DATE_BUCKET_ORDER)[number];
|
||||
|
||||
const DATE_BUCKET_LABELS: Record<DateBucket, string> = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
this_week: "This week",
|
||||
earlier: "Earlier",
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Bucket a timestamp relative to `now` using a rolling calendar-day window. */
|
||||
export function attentionDateBucket(activityAt: string, now: number): DateBucket {
|
||||
const ts = new Date(activityAt).getTime();
|
||||
if (!Number.isFinite(ts)) return "earlier";
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const todayStart = startOfToday.getTime();
|
||||
if (ts >= todayStart) return "today";
|
||||
if (ts >= todayStart - MS_PER_DAY) return "yesterday";
|
||||
// Rolling 7-day window from the start of today (locale week-start agnostic).
|
||||
if (ts >= todayStart - 6 * MS_PER_DAY) return "this_week";
|
||||
return "earlier";
|
||||
}
|
||||
|
||||
const SEVERITY_LABEL: Record<AttentionSeverity, string> = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bucket items into ordered sections. Item order *within* each group is
|
||||
* preserved from the input (which the caller sorts first), so the sort toggle
|
||||
* still governs intra-group ordering. Group ordering is fixed for date/severity
|
||||
* and most-recent-first for type/project.
|
||||
*/
|
||||
export function groupAttentionItems(
|
||||
items: AttentionItem[],
|
||||
groupBy: AttentionGroupBy,
|
||||
options: { now?: number } = {},
|
||||
): AttentionGroup[] {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
if (groupBy === "date") {
|
||||
const now = options.now ?? Date.now();
|
||||
const buckets = new Map<DateBucket, AttentionItem[]>();
|
||||
for (const item of items) {
|
||||
const bucket = attentionDateBucket(item.activityAt, now);
|
||||
const list = buckets.get(bucket) ?? [];
|
||||
list.push(item);
|
||||
buckets.set(bucket, list);
|
||||
}
|
||||
return DATE_BUCKET_ORDER.filter((bucket) => buckets.has(bucket)).map((bucket) => ({
|
||||
key: `date:${bucket}`,
|
||||
label: DATE_BUCKET_LABELS[bucket],
|
||||
items: buckets.get(bucket)!,
|
||||
}));
|
||||
}
|
||||
|
||||
if (groupBy === "severity") {
|
||||
const buckets = new Map<AttentionSeverity, AttentionItem[]>();
|
||||
for (const item of items) {
|
||||
const list = buckets.get(item.severity) ?? [];
|
||||
list.push(item);
|
||||
buckets.set(item.severity, list);
|
||||
}
|
||||
return ALL_SEVERITIES.filter((s) => buckets.has(s)).map((severity) => ({
|
||||
key: `severity:${severity}`,
|
||||
label: SEVERITY_LABEL[severity],
|
||||
items: buckets.get(severity)!,
|
||||
}));
|
||||
}
|
||||
|
||||
// type / project: group, then order groups by most-recent activity so the
|
||||
// freshest section floats to the top (matching Inbox's issue-group ordering).
|
||||
const groups = new Map<string, { label: string; items: AttentionItem[]; latest: number }>();
|
||||
for (const item of items) {
|
||||
const resolved =
|
||||
groupBy === "type"
|
||||
? { key: `type:${item.sourceKind}`, label: sourceMeta(item.sourceKind).label }
|
||||
: item.project
|
||||
? { key: `project:${item.project.id}`, label: item.project.name }
|
||||
: { key: `project:${NO_GROUP_SENTINEL}`, label: "No project" };
|
||||
const existing = groups.get(resolved.key);
|
||||
const ts = attentionActivityTimestamp(item);
|
||||
if (existing) {
|
||||
existing.items.push(item);
|
||||
existing.latest = Math.max(existing.latest, ts);
|
||||
} else {
|
||||
groups.set(resolved.key, { label: resolved.label, items: [item], latest: ts });
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort(([, a], [, b]) => {
|
||||
const diff = b.latest - a.latest;
|
||||
if (diff !== 0) return diff;
|
||||
return a.label.localeCompare(b.label);
|
||||
})
|
||||
.map(([key, value]) => ({ key, label: value.label, items: value.items }));
|
||||
}
|
||||
@@ -387,7 +387,9 @@ describe("inbox helpers", () => {
|
||||
companyId: "company-1",
|
||||
userId: "user-1",
|
||||
itemKey: "approval:approval-1",
|
||||
kind: "dismiss",
|
||||
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
snoozedUntil: null,
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
|
||||
+504
-48
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Inbox } from "lucide-react";
|
||||
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -9,26 +9,85 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useInboxDismissals } from "../hooks/useInboxBadge";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { isInlineResolvable } from "../lib/attention";
|
||||
import {
|
||||
ATTENTION_GROUP_BY_OPTIONS,
|
||||
ATTENTION_SORT_OPTIONS,
|
||||
buildAttentionFilterOptions,
|
||||
countActiveAttentionFilters,
|
||||
defaultAttentionFilterState,
|
||||
filterAttentionItems,
|
||||
groupAttentionItems,
|
||||
isInlineResolvable,
|
||||
loadAttentionFilters,
|
||||
loadAttentionGroupBy,
|
||||
loadAttentionSortOrder,
|
||||
loadCollapsedAttentionGroupKeys,
|
||||
NO_GROUP_SENTINEL,
|
||||
saveAttentionFilters,
|
||||
saveAttentionGroupBy,
|
||||
saveAttentionSortOrder,
|
||||
saveCollapsedAttentionGroupKeys,
|
||||
sortAttentionItems,
|
||||
sourceMeta,
|
||||
type AttentionFilterState,
|
||||
type AttentionGroupBy,
|
||||
type AttentionSortOrder,
|
||||
} from "../lib/attention";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
export function WhatNeedsMe() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [autoExpandDone, setAutoExpandDone] = useState(false);
|
||||
const [clearedIds, setClearedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const { dismiss, dismissedAtByKey } = useInboxDismissals(selectedCompanyId);
|
||||
// Toolbar preferences (persisted to localStorage, Inbox pattern).
|
||||
const [groupBy, setGroupBy] = useState<AttentionGroupBy>(() => loadAttentionGroupBy());
|
||||
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
|
||||
const [filters, setFilters] = useState<AttentionFilterState>(() => defaultAttentionFilterState);
|
||||
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
|
||||
const [snoozedOpen, setSnoozedOpen] = useState(false);
|
||||
const [dismissedOpen, setDismissedOpen] = 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());
|
||||
|
||||
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "What needs me" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: feed, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.attention(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!),
|
||||
// Re-hydrate per-company preferences when the company changes.
|
||||
useEffect(() => {
|
||||
setFilters(loadAttentionFilters(selectedCompanyId));
|
||||
setCollapsedGroupKeys(loadCollapsedAttentionGroupKeys(selectedCompanyId));
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const {
|
||||
data: feed,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
// Distinct from the sidebar badge's `queryKeys.attention` so dismissed rows
|
||||
// (needed for the curtains) never inflate the badge count. Invalidating the
|
||||
// `["attention", companyId]` prefix still cascades to this query.
|
||||
queryKey: [...queryKeys.attention(selectedCompanyId!), "with-dismissed"],
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { includeDismissed: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
@@ -51,35 +110,95 @@ export function WhatNeedsMe() {
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
// Client-side guard: the server already filters dismissed rows, but this
|
||||
// keeps a just-dismissed row from flashing back before the refetch lands.
|
||||
const items = useMemo(() => {
|
||||
const list = feed?.items ?? [];
|
||||
return list.filter((item) => {
|
||||
if (clearedIds.has(item.id)) return false;
|
||||
const dismissedAt = dismissedAtByKey.get(item.dismissalKey);
|
||||
return !(dismissedAt != null && dismissedAt >= new Date(item.activityAt).getTime());
|
||||
});
|
||||
}, [feed, clearedIds, dismissedAtByKey]);
|
||||
|
||||
// Auto-expand the topmost inline-capable decision, once, per converged UX.
|
||||
// Rank never reorders while interacting, so we only seed the default once.
|
||||
// Reset optimistic state once the server sends a fresh snapshot.
|
||||
useEffect(() => {
|
||||
if (autoExpandDone || items.length === 0) return;
|
||||
const topInline = items.find((item) => isInlineResolvable(item));
|
||||
setPendingHide(new Set());
|
||||
setPendingRestore(new Set());
|
||||
}, [feed?.generatedAt]);
|
||||
|
||||
const allItems = useMemo(() => feed?.items ?? [], [feed]);
|
||||
|
||||
const isServerHidden = (item: AttentionItem) => item.dismissal != null && item.dismissal.isActive;
|
||||
|
||||
const activeItems = useMemo(
|
||||
() => allItems.filter((item) => !isServerHidden(item) && !pendingHide.has(item.id)),
|
||||
[allItems, pendingHide],
|
||||
);
|
||||
const snoozedItems = useMemo(
|
||||
() =>
|
||||
allItems.filter(
|
||||
(item) =>
|
||||
item.dismissal?.kind === "snooze" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
||||
),
|
||||
[allItems, pendingRestore],
|
||||
);
|
||||
const dismissedItems = useMemo(
|
||||
() =>
|
||||
allItems.filter(
|
||||
(item) =>
|
||||
item.dismissal?.kind === "dismiss" && item.dismissal.isActive && !pendingRestore.has(item.id),
|
||||
),
|
||||
[allItems, pendingRestore],
|
||||
);
|
||||
|
||||
const filterOptions = useMemo(() => buildAttentionFilterOptions(activeItems), [activeItems]);
|
||||
|
||||
// Filter → sort → group, all client-side so switching re-buckets without a refetch.
|
||||
const groups = useMemo(() => {
|
||||
const filtered = filterAttentionItems(activeItems, filters);
|
||||
const sorted = sortAttentionItems(filtered, sortOrder);
|
||||
return groupAttentionItems(sorted, groupBy);
|
||||
}, [activeItems, filters, sortOrder, groupBy]);
|
||||
|
||||
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
|
||||
|
||||
// Auto-expand the topmost inline-capable decision, once.
|
||||
useEffect(() => {
|
||||
if (autoExpandDone || activeItems.length === 0) return;
|
||||
const sorted = sortAttentionItems(activeItems, sortOrder);
|
||||
const topInline = sorted.find((item) => isInlineResolvable(item));
|
||||
if (topInline) setExpandedId(topInline.id);
|
||||
setAutoExpandDone(true);
|
||||
}, [items, autoExpandDone]);
|
||||
}, [activeItems, autoExpandDone, sortOrder]);
|
||||
|
||||
const handleDismiss = (item: AttentionItem) => {
|
||||
dismiss(item.dismissalKey);
|
||||
setClearedIds((prev) => {
|
||||
const updateGroupBy = (next: AttentionGroupBy) => {
|
||||
setGroupBy(next);
|
||||
saveAttentionGroupBy(next);
|
||||
};
|
||||
const updateSortOrder = (next: AttentionSortOrder) => {
|
||||
setSortOrder(next);
|
||||
saveAttentionSortOrder(next);
|
||||
};
|
||||
const updateFilters = (next: AttentionFilterState) => {
|
||||
setFilters(next);
|
||||
saveAttentionFilters(selectedCompanyId, next);
|
||||
};
|
||||
const toggleGroupCollapse = (key: string) => {
|
||||
setCollapsedGroupKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(item.id);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
saveCollapsedAttentionGroupKeys(selectedCompanyId, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDismiss = (item: AttentionItem) => {
|
||||
setPendingHide((prev) => new Set(prev).add(item.id));
|
||||
dismiss(item.dismissalKey);
|
||||
if (expandedId === item.id) setExpandedId(null);
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
const activeFilterCount = countActiveAttentionFilters(filters);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
@@ -89,36 +208,373 @@ export function WhatNeedsMe() {
|
||||
return <PageSkeleton variant="approvals" />;
|
||||
}
|
||||
|
||||
const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-bold">What needs me</h1>
|
||||
{items.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{items.length} {items.length === 1 ? "decision" : "decisions"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{visibleCount > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
|
||||
</span>
|
||||
)}
|
||||
{/* Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", activeFilterCount > 0 && "bg-accent")}
|
||||
title="Filter"
|
||||
aria-label="Filter"
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-0">
|
||||
<FilterMenu
|
||||
options={filterOptions}
|
||||
filters={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Group by */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", groupBy !== "date" && "bg-accent")}
|
||||
title="Group"
|
||||
aria-label="Group"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-2">
|
||||
<div className="space-y-0.5">
|
||||
{ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => updateGroupBy(value)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Sort */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title="Sort"
|
||||
aria-label="Sort"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-2">
|
||||
<div className="space-y-0.5">
|
||||
{ATTENTION_SORT_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||
sortOrder === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => updateSortOrder(value)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{sortOrder === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
||||
|
||||
{items.length === 0 ? (
|
||||
{!hasAnything ? (
|
||||
<ZeroState />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
|
||||
onDismiss={handleDismiss}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
<div className="space-y-4">
|
||||
{visibleCount === 0 ? (
|
||||
<CaughtUpNote filtered={activeItems.length > 0} />
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const collapsed = collapsedGroupKeys.has(group.key);
|
||||
return (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onToggle={() => toggleGroupCollapse(group.key)}
|
||||
trailing={
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>
|
||||
}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{snoozedItems.length > 0 && (
|
||||
<Curtain
|
||||
label="Snoozed"
|
||||
count={snoozedItems.length}
|
||||
open={snoozedOpen}
|
||||
onToggle={() => setSnoozedOpen((prev) => !prev)}
|
||||
>
|
||||
{snoozedItems.map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
variant="hidden"
|
||||
expanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onDismiss={handleDismiss}
|
||||
onRestore={handleRestore}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</Curtain>
|
||||
)}
|
||||
|
||||
{dismissedItems.length > 0 && (
|
||||
<Curtain
|
||||
label="Dismissed"
|
||||
count={dismissedItems.length}
|
||||
open={dismissedOpen}
|
||||
onToggle={() => setDismissedOpen((prev) => !prev)}
|
||||
>
|
||||
{dismissedItems.map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
variant="hidden"
|
||||
expanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onDismiss={handleDismiss}
|
||||
onRestore={handleRestore}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
))}
|
||||
</Curtain>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenu({
|
||||
options,
|
||||
filters,
|
||||
onChange,
|
||||
}: {
|
||||
options: ReturnType<typeof buildAttentionFilterOptions>;
|
||||
filters: AttentionFilterState;
|
||||
onChange: (next: AttentionFilterState) => void;
|
||||
}) {
|
||||
const toggle = (key: keyof AttentionFilterState, value: string) => {
|
||||
const list = filters[key] as string[];
|
||||
const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
||||
onChange({ ...filters, [key]: nextList });
|
||||
};
|
||||
const hasActive = countActiveAttentionFilters(filters) > 0;
|
||||
|
||||
return (
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
|
||||
{hasActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onChange(defaultAttentionFilterState)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.sourceKinds.length > 1 && (
|
||||
<FilterSection title="Type">
|
||||
{options.sourceKinds.map((kind) => (
|
||||
<FilterRow
|
||||
key={kind}
|
||||
label={sourceMeta(kind).label}
|
||||
checked={filters.sourceKinds.includes(kind)}
|
||||
onToggle={() => toggle("sourceKinds", kind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{options.severities.length > 1 && (
|
||||
<FilterSection title="Severity">
|
||||
{options.severities.map((severity) => (
|
||||
<FilterRow
|
||||
key={severity}
|
||||
label={SEVERITY_LABELS[severity] ?? severity}
|
||||
checked={filters.severities.includes(severity)}
|
||||
onToggle={() => toggle("severities", severity)}
|
||||
/>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{(options.projects.length > 0 || options.hasNoProject) && (
|
||||
<FilterSection title="Project">
|
||||
{options.projects.map((project) => (
|
||||
<FilterRow
|
||||
key={project.id}
|
||||
label={project.name}
|
||||
checked={filters.projectIds.includes(project.id)}
|
||||
onToggle={() => toggle("projectIds", project.id)}
|
||||
/>
|
||||
))}
|
||||
{options.hasNoProject && (
|
||||
<FilterRow
|
||||
label="No project"
|
||||
checked={filters.projectIds.includes(NO_GROUP_SENTINEL)}
|
||||
onToggle={() => toggle("projectIds", NO_GROUP_SENTINEL)}
|
||||
/>
|
||||
)}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{(options.workspaces.length > 0 || options.hasNoWorkspace) && (
|
||||
<FilterSection title="Workspace">
|
||||
{options.workspaces.map((workspace) => (
|
||||
<FilterRow
|
||||
key={workspace.id}
|
||||
label={workspace.name}
|
||||
checked={filters.workspaceIds.includes(workspace.id)}
|
||||
onToggle={() => toggle("workspaceIds", workspace.id)}
|
||||
/>
|
||||
))}
|
||||
{options.hasNoWorkspace && (
|
||||
<FilterRow
|
||||
label="No workspace"
|
||||
checked={filters.workspaceIds.includes(NO_GROUP_SENTINEL)}
|
||||
onToggle={() => toggle("workspaceIds", NO_GROUP_SENTINEL)}
|
||||
/>
|
||||
)}
|
||||
</FilterSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="border-t border-border/60 px-2 py-1.5">
|
||||
<p className="px-1 pb-1 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({
|
||||
label,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-left text-sm hover:bg-accent/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" tabIndex={-1} />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Curtain({
|
||||
label,
|
||||
count,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<IssueGroupHeader
|
||||
label={`${label} (${count})`}
|
||||
collapsible
|
||||
collapsed={!open}
|
||||
onToggle={onToggle}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
{open && <div className="space-y-2">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CaughtUpNote({ filtered }: { filtered: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{filtered ? "No decisions match your filters." : "You're all caught up."}
|
||||
</p>
|
||||
{filtered && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Adjust or clear the filters to see the rest.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,10 @@ function item(
|
||||
href: "/PAP/issues/PAP-1000",
|
||||
metadata: {},
|
||||
},
|
||||
project: null,
|
||||
workspace: null,
|
||||
detail: null,
|
||||
dismissal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user