From 5b98e17c4fb27e2f72b85d89cae4ece68fe3714f Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Jul 2026 14:35:20 +0000 Subject: [PATCH] PAP-13408 T2: Inbox-style attention toolbar, date grouping, curtains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/api/inboxDismissals.ts | 8 + ui/src/components/AttentionQueueRow.test.tsx | 4 + ui/src/components/AttentionQueueRow.tsx | 132 ++++- ui/src/hooks/useInboxBadge.ts | 27 +- ui/src/lib/attention.test.ts | 176 +++++- ui/src/lib/attention.ts | 371 +++++++++++- ui/src/lib/inbox.test.ts | 2 + ui/src/pages/WhatNeedsMe.tsx | 552 ++++++++++++++++-- .../stories/what-needs-me.stories.tsx | 4 + 9 files changed, 1201 insertions(+), 75 deletions(-) diff --git a/ui/src/api/inboxDismissals.ts b/ui/src/api/inboxDismissals.ts index f80d3aef966..e8de87dfd1c 100644 --- a/ui/src/api/inboxDismissals.ts +++ b/ui/src/api/inboxDismissals.ts @@ -5,4 +5,12 @@ export const inboxDismissalsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/inbox-dismissals`), dismiss: (companyId: string, itemKey: string) => api.post(`/companies/${companyId}/inbox-dismissals`, { itemKey }), + snooze: (companyId: string, itemKey: string, snoozedUntil: string) => + api.post(`/companies/${companyId}/inbox-dismissals`, { + itemKey, + kind: "snooze", + snoozedUntil, + }), + restore: (companyId: string, itemKey: string) => + api.delete(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`), }; diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index bb9c7f7cf18..84197d608eb 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -74,6 +74,10 @@ function buildItem(overrides: Partial = {}): AttentionItem { createdAt: "2026-07-09T12:00:00Z", updatedAt: "2026-07-09T12:00:00Z", relatedIssue: null, + project: null, + workspace: null, + detail: null, + dismissal: null, ...overrides, }; } diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index 74ddb8d61ee..eea18ec5f36 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -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; currentUserId?: string | null; userLabelMap?: ReadonlyMap | 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 (
)} - - - {relativeTime(item.activityAt)} - + {isHidden && snoozedUntil ? ( + + + Reappears {reappearLabel(snoozedUntil)} + + ) : ( + + + {relativeTime(item.activityAt)} + + )}
@@ -131,27 +167,67 @@ export function AttentionQueueRow({ )} - {/* Snooze / dismiss */} - - + {isHidden ? ( + /* Curtain rows: restore back into the live queue. */ + onRestore && ( - - - onDismiss(item)}>Snooze / dismiss - {href && ( - - Open source + ) + ) : ( + /* Active rows: snooze presets + dismiss. */ + + + + + + {onSnooze && ( + + + + Snooze + + + {SNOOZE_PRESETS.map((preset) => ( + onSnooze(item, new Date(Date.now() + preset.ms).toISOString())} + > + {preset.label} + + ))} + + + )} + onDismiss(item)}> + + Dismiss - )} - - + {href && ( + <> + + + Open source + + + )} + + + )}
@@ -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, diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index a2aac8a9925..a12c6c7addf 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -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, }; } diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index 99d22bea1b2..0ac4823fd36 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -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 { return { @@ -21,6 +34,10 @@ function buildItem(overrides: Partial = {}): 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); + }); +}); diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index f7b99c10005..38ca0661910 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -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; + 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 { + 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, +) { + 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(); + const projects = new Map(); + const workspaces = new Map(); + const severities = new Set(); + 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 = { + 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 = { + 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(); + 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(); + 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(); + 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 })); +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 8f0468cb692..7aafda51669 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -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"), }, diff --git a/ui/src/pages/WhatNeedsMe.tsx b/ui/src/pages/WhatNeedsMe.tsx index aeb726c0be1..eae65a640d4 100644 --- a/ui/src/pages/WhatNeedsMe.tsx +++ b/ui/src/pages/WhatNeedsMe.tsx @@ -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 = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; export function WhatNeedsMe() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [expandedId, setExpandedId] = useState(null); const [autoExpandDone, setAutoExpandDone] = useState(false); - const [clearedIds, setClearedIds] = useState>(() => new Set()); - const { dismiss, dismissedAtByKey } = useInboxDismissals(selectedCompanyId); + // Toolbar preferences (persisted to localStorage, Inbox pattern). + const [groupBy, setGroupBy] = useState(() => loadAttentionGroupBy()); + const [sortOrder, setSortOrder] = useState(() => loadAttentionSortOrder()); + const [filters, setFilters] = useState(() => defaultAttentionFilterState); + const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => 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>(() => new Set()); + const [pendingRestore, setPendingRestore] = useState>(() => 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

Select a company first.

; @@ -89,36 +208,373 @@ export function WhatNeedsMe() { return ; } + const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0; + return ( -
-
+
+

What needs me

- {items.length > 0 && ( - - {items.length} {items.length === 1 ? "decision" : "decisions"} - - )} +
+ {visibleCount > 0 && ( + + {visibleCount} {visibleCount === 1 ? "decision" : "decisions"} + + )} + {/* Filter */} + + + + + + + + + {/* Group by */} + + + + + +
+ {ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => ( + + ))} +
+
+
+ {/* Sort */} + + + + + +
+ {ATTENTION_SORT_OPTIONS.map(([value, label]) => ( + + ))} +
+
+
+
{error &&

{(error as Error).message}

} - {items.length === 0 ? ( + {!hasAnything ? ( ) : ( -
- {items.map((item) => ( - setExpandedId((prev) => (prev === item.id ? null : item.id))} - onDismiss={handleDismiss} - agentMap={agentMap} - currentUserId={currentUserId} +
+ {visibleCount === 0 ? ( + 0} /> + ) : ( + groups.map((group) => { + const collapsed = collapsedGroupKeys.has(group.key); + return ( +
+ toggleGroupCollapse(group.key)} + trailing={ + {group.items.length} + } + /> + {!collapsed && ( +
+ {group.items.map((item) => ( + setExpandedId((prev) => (prev === item.id ? null : item.id))} + onDismiss={handleDismiss} + onSnooze={handleSnooze} + agentMap={agentMap} + currentUserId={currentUserId} + /> + ))} +
+ )} +
+ ); + }) + )} + + {snoozedItems.length > 0 && ( + setSnoozedOpen((prev) => !prev)} + > + {snoozedItems.map((item) => ( + {}} + onDismiss={handleDismiss} + onRestore={handleRestore} + agentMap={agentMap} + currentUserId={currentUserId} + /> + ))} + + )} + + {dismissedItems.length > 0 && ( + setDismissedOpen((prev) => !prev)} + > + {dismissedItems.map((item) => ( + {}} + onDismiss={handleDismiss} + onRestore={handleRestore} + agentMap={agentMap} + currentUserId={currentUserId} + /> + ))} + + )} +
+ )} +
+ ); +} + +function FilterMenu({ + options, + filters, + onChange, +}: { + options: ReturnType; + 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 ( +
+
+ Filter + {hasActive && ( + + )} +
+ + {options.sourceKinds.length > 1 && ( + + {options.sourceKinds.map((kind) => ( + toggle("sourceKinds", kind)} /> ))} -
+ + )} + + {options.severities.length > 1 && ( + + {options.severities.map((severity) => ( + toggle("severities", severity)} + /> + ))} + + )} + + {(options.projects.length > 0 || options.hasNoProject) && ( + + {options.projects.map((project) => ( + toggle("projectIds", project.id)} + /> + ))} + {options.hasNoProject && ( + toggle("projectIds", NO_GROUP_SENTINEL)} + /> + )} + + )} + + {(options.workspaces.length > 0 || options.hasNoWorkspace) && ( + + {options.workspaces.map((workspace) => ( + toggle("workspaceIds", workspace.id)} + /> + ))} + {options.hasNoWorkspace && ( + toggle("workspaceIds", NO_GROUP_SENTINEL)} + /> + )} + + )} +
+ ); +} + +function FilterSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function FilterRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function Curtain({ + label, + count, + open, + onToggle, + children, +}: { + label: string; + count: number; + open: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {open &&
{children}
} +
+ ); +} + +function CaughtUpNote({ filtered }: { filtered: boolean }) { + return ( +
+

+ {filtered ? "No decisions match your filters." : "You're all caught up."} +

+ {filtered && ( +

Adjust or clear the filters to see the rest.

)}
); diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/what-needs-me.stories.tsx index eed06698d89..2200f90ae66 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/what-needs-me.stories.tsx @@ -54,6 +54,10 @@ function item( href: "/PAP/issues/PAP-1000", metadata: {}, }, + project: null, + workspace: null, + detail: null, + dismissal: null, ...overrides, }; }