From ad58432220ea2ebfd176635597ac2b3ed1812e95 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Jul 2026 17:31:03 +0000 Subject: [PATCH] Fix attention group-by default Co-Authored-By: Paperclip --- ui/src/lib/attention.test.ts | 39 ++++++++++++- ui/src/lib/attention.ts | 24 ++++---- ui/src/pages/WhatNeedsMe.tsx | 25 +++++---- .../stories/what-needs-me.stories.tsx | 55 ++++++++++--------- 4 files changed, 96 insertions(+), 47 deletions(-) diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index b17adfac3f8..d5ea99039e9 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared"; import { + ATTENTION_GROUP_BY_KEY, + ATTENTION_GROUP_BY_OPTIONS, attentionBadgeCount, attentionDateBucket, attentionDetailLine, @@ -12,7 +14,9 @@ import { filterAttentionItems, groupAttentionItems, isInlineResolvable, + loadAttentionGroupBy, NO_GROUP_SENTINEL, + saveAttentionGroupBy, severityBadge, severityStyle, sortAttentionItems, @@ -46,6 +50,25 @@ function buildItem(overrides: Partial = {}): AttentionItem { }; } +describe("attention group preference persistence", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to None and lists it as the first group option", () => { + expect(loadAttentionGroupBy()).toBe("none"); + expect(ATTENTION_GROUP_BY_OPTIONS[0]).toEqual(["none", "None"]); + }); + + it("round-trips explicit grouped choices and treats stale values as None", () => { + saveAttentionGroupBy("date"); + expect(loadAttentionGroupBy()).toBe("date"); + + localStorage.setItem(ATTENTION_GROUP_BY_KEY, "unexpected"); + expect(loadAttentionGroupBy()).toBe("none"); + }); +}); + describe("isInlineResolvable", () => { it("is true for approvals/interactions/join when server flags inlineResolvable", () => { for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) { @@ -252,6 +275,20 @@ describe("attentionDateBucket", () => { describe("groupAttentionItems", () => { const now = new Date("2026-07-10T12:00:00Z").getTime(); + it("leaves None as one unlabeled group that preserves caller sort order", () => { + const items = sortAttentionItems( + [ + buildItem({ id: "old", activityAt: "2026-07-10T08:00:00Z" }), + buildItem({ id: "new", activityAt: "2026-07-10T10:00:00Z" }), + ], + "newest", + ); + const groups = groupAttentionItems(items, "none", { now }); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBeNull(); + expect(groups[0].items.map((i) => i.id)).toEqual(["new", "old"]); + }); + it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => { const items = [ buildItem({ id: "earlier", activityAt: "2026-06-01T00:00:00Z" }), diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index d1e14485ce4..eca5e31d0ff 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -276,18 +276,18 @@ export function attentionBadgeCount(feed: AttentionFeed | null | undefined): num // --------------------------------------------------------------------------- // 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. +// The queue defaults to no grouping, 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 AttentionGroupBy = "none" | "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]> = [ + ["none", "None"], ["date", "Date"], ["type", "Type"], ["project", "Project"], @@ -321,7 +321,7 @@ export const defaultAttentionFilterState: AttentionFilterState = { export interface AttentionGroup { key: string; - label: string; + label: string | null; items: AttentionItem[]; } @@ -342,15 +342,15 @@ 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"; + return value === "none" || 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"; + return isAttentionGroupBy(raw) ? raw : "none"; } catch { - return "date"; + return "none"; } } @@ -576,6 +576,10 @@ export function groupAttentionItems( ): AttentionGroup[] { if (items.length === 0) return []; + if (groupBy === "none") { + return [{ key: "__all", label: null, items }]; + } + if (groupBy === "date") { const now = options.now ?? Date.now(); const buckets = new Map(); diff --git a/ui/src/pages/WhatNeedsMe.tsx b/ui/src/pages/WhatNeedsMe.tsx index e320902763d..4e5fdd369eb 100644 --- a/ui/src/pages/WhatNeedsMe.tsx +++ b/ui/src/pages/WhatNeedsMe.tsx @@ -273,7 +273,7 @@ export function WhatNeedsMe() { type="button" variant="outline" size="icon" - className={cn("h-8 w-8 shrink-0", groupBy !== "date" && "bg-accent")} + className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")} title="Group" aria-label="Group" > @@ -345,18 +345,21 @@ export function WhatNeedsMe() { 0} /> ) : ( groups.map((group) => { - const collapsed = collapsedGroupKeys.has(group.key); + const groupLabel = group.label; + const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key); return (
- toggleGroupCollapse(group.key)} - trailing={ - {group.items.length} - } - /> + {groupLabel !== null && ( + toggleGroupCollapse(group.key)} + trailing={ + {group.items.length} + } + /> + )} {!collapsed && (
{group.items.map((item) => ( diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/what-needs-me.stories.tsx index bf3f0bfb930..5fc41fde7f0 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/what-needs-me.stories.tsx @@ -309,7 +309,7 @@ function ToolbarButton({ icon: Icon, active }: { icon: typeof Layers; active?: b function Queue({ items, - groupBy = "date", + groupBy = "none", sortOrder = "newest", snoozed = [], dismissed = [], @@ -344,7 +344,7 @@ function Queue({ )} - +
@@ -361,30 +361,35 @@ function Queue({ ) : (
- {groups.map((group) => ( -
- {group.items.length}} - /> -
- {group.items.map((it) => ( - setExpandedId((p) => (p === it.id ? null : it.id))} - onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))} - onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))} - onFilterProject={() => {}} + {groups.map((group) => { + const groupLabel = group.label; + return ( +
+ {groupLabel !== null && ( + {group.items.length}} /> - ))} -
-
- ))} + )} +
+ {group.items.map((it) => ( + setExpandedId((p) => (p === it.id ? null : it.id))} + onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))} + onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))} + onFilterProject={() => {}} + /> + ))} +
+
+ ); + })} {snoozed.length > 0 && (