From 44597a8281afe7143a2f0ef9a2ba609841068a33 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:03:24 +0200 Subject: [PATCH] feat(web): recency headers on Sidebar V2 settled tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partition stays lifecycle-first (active / snoozed / settled). Within the settled shelf, group already-sorted history by Last Hour / Earlier Today / … via shared thread-recency-groups. Suppress headers when only one bucket is visible on the current page so paging stays quiet. --- apps/web/src/components/Sidebar.logic.test.ts | 57 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 38 +++++++++++++ apps/web/src/components/SidebarV2.tsx | 36 +++++++++++- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cc1e3d82536..79f89e84709 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -34,6 +34,7 @@ import { shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, + groupSettledThreadsByRecencyForSidebarV2, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, @@ -1163,6 +1164,62 @@ describe("sortSettledThreadsForSidebarV2", () => { }); }); +describe("groupSettledThreadsByRecencyForSidebarV2", () => { + // Fixed local afternoon so last-hour and earlier-today both fit the day. + const now = new Date(2026, 2, 15, 14, 30, 0); + + const settled = (input: { + id: string; + settledAt?: string | null; + latestUserMessageAt?: string | null; + updatedAt?: string; + }) => ({ + id: input.id, + settledAt: input.settledAt ?? null, + latestUserMessageAt: input.latestUserMessageAt ?? null, + latestTurn: null, + updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", + }); + + it("groups by settle/activity time and shows headers when multiple buckets", () => { + const lastHourIso = new Date(now.getTime() - 5 * 60_000).toISOString(); + const olderIso = new Date( + new Date(2026, 2, 15).getTime() - 40 * 24 * 60 * 60 * 1000, + ).toISOString(); + const ordered = sortSettledThreadsForSidebarV2([ + settled({ id: "old", settledAt: olderIso }), + settled({ id: "fresh", settledAt: lastHourIso }), + ]); + const layout = groupSettledThreadsByRecencyForSidebarV2(ordered, now); + expect(layout.showHeaders).toBe(true); + expect(layout.groups.map((group) => group.id)).toEqual(["last_hour", "older"]); + expect(layout.groups[0]?.threads.map((thread) => thread.id)).toEqual(["fresh"]); + expect(layout.groups[1]?.threads.map((thread) => thread.id)).toEqual(["old"]); + }); + + it("suppresses headers when every row is in one bucket", () => { + const lastHourIso = new Date(now.getTime() - 5 * 60_000).toISOString(); + const layout = groupSettledThreadsByRecencyForSidebarV2( + [settled({ id: "a", settledAt: lastHourIso }), settled({ id: "b", settledAt: lastHourIso })], + now, + ); + expect(layout.showHeaders).toBe(false); + expect(layout.groups).toHaveLength(1); + expect(layout.groups[0]?.threads.map((thread) => thread.id)).toEqual(["a", "b"]); + }); + + it("preserves input order within a bucket", () => { + const t1 = new Date(now.getTime() - 2 * 60_000).toISOString(); + const t2 = new Date(now.getTime() - 10 * 60_000).toISOString(); + // Caller is expected to pre-sort; newer first. + const layout = groupSettledThreadsByRecencyForSidebarV2( + [settled({ id: "newer", settledAt: t1 }), settled({ id: "older-hour", settledAt: t2 })], + now, + ); + expect(layout.groups[0]?.threads.map((thread) => thread.id)).toEqual(["newer", "older-hour"]); + }); +}); + describe("resolveWorkingStartedAt", () => { const session = { threadId: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ae4559e47ea..36218b67f71 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -3,6 +3,11 @@ import { effectiveSettled, type ChangeRequestStateLike, } from "@t3tools/client-runtime/state/thread-settled"; +import { + groupThreadsByRecency, + shouldShowRecencySectionHeaders, + type ThreadRecencyGroup, +} from "@t3tools/client-runtime/state/thread-recency-groups"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { @@ -948,6 +953,39 @@ export function sortSettledThreadsForSidebarV2< ); } +/** + * Recency section layout for the V2 settled shelf. Callers must pass threads + * already ordered by {@link sortSettledThreadsForSidebarV2} (or an equivalent + * activity/settle-time order) so buckets preserve that order within each day. + * + * Headers are suppressed when every visible row lands in a single bucket + * (same rule as classic Threads recency). + */ +export function groupSettledThreadsByRecencyForSidebarV2< + T extends SettledTimestampInput & { readonly id: string }, +>( + threads: readonly T[], + now: Date = new Date(), +): { + readonly groups: ReadonlyArray>; + readonly showHeaders: boolean; +} { + const groups = groupThreadsByRecency( + threads, + (thread) => { + const timestamp = resolveSettledTimestamp(thread); + if (timestamp === null) return Number.NaN; + const ms = Date.parse(timestamp); + return Number.isNaN(ms) ? Number.NaN : ms; + }, + now, + ); + return { + groups, + showHeaders: shouldShowRecencySectionHeaders(groups), + }; +} + /** The timestamp a working thread's elapsed label counts from: the running turn's start (request time until adoption), falling back to the session's last transition when the turn projection lags behind. Malformed diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3cf240f9efa..e6ec1eca9b6 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -111,6 +111,7 @@ import { buildSidebarV2ThreadContextMenuItems, formatWorkingDurationLabel, firstValidTimestampMs, + groupSettledThreadsByRecencyForSidebarV2, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -1493,6 +1494,14 @@ export default function SidebarV2() { return routeThread === undefined ? [] : [routeThread]; }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + // Date headers on the settled tail only (lifecycle spine stays intact). + // Recompute when the minute clock advances so Last Hour / Earlier Today + // boundaries stay honest. Single-bucket pages omit headers. + const settledRecencyLayout = useMemo(() => { + void nowMinute; + return groupSettledThreadsByRecencyForSidebarV2(renderedSettledThreads, new Date()); + }, [nowMinute, renderedSettledThreads]); + // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. @@ -2546,8 +2555,31 @@ export default function SidebarV2() { , ); } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); + // Recency headers only when multiple buckets are visible on + // this page (Last Hour / Earlier Today / …). Jump keys and + // multi-select still walk row threads only. + if (settledRecencyLayout.showHeaders) { + for (const group of settledRecencyLayout.groups) { + items.push( +
  • +
    + {group.label} +
    +
  • , + ); + for (const thread of group.threads) { + items.push(renderThreadRow(thread, "settled")); + } + } + } else { + for (const thread of renderedSettledThreads) { + items.push(renderThreadRow(thread, "settled")); + } } return items; })()}