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:
DottaandClaude Opus 4.8 committed 2026-07-10 20:08:18 +00:00
1 parent 9dcdb3ce60
commit 5b98e17c4f
9 files changed
+1201 -75

No files matched your search

+8
View File
@@ -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,
};
}
+110 -22
View File
@@ -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,
+24 -3
View File
@@ -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,
};
}
+175 -1
View File
@@ -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
View File
@@ -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 }));
}
+2
View File
@@ -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
View File
@@ -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,
};
}