Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
shouldNavigateAfterProjectRemoval,
shouldClearThreadSelectionOnMouseDown,
sortLogicalProjectsForSidebar,
groupSettledThreadsByRecencyForSidebarV2,
sortSettledThreadsForSidebarV2,
sortThreadsForSidebarV2,
sortProjectsForSidebar,
Expand Down Expand Up @@ -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"),
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ThreadRecencyGroup<T>>;
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
Expand Down
36 changes: 34 additions & 2 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
buildSidebarV2ThreadContextMenuItems,
formatWorkingDurationLabel,
firstValidTimestampMs,
groupSettledThreadsByRecencyForSidebarV2,
hasUnseenCompletion,
isTrailingDoubleClick,
orderItemsByPreferredIds,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2546,8 +2555,31 @@ export default function SidebarV2() {
</li>,
);
}
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(
<li
key={`settled-recency-${group.id}`}
data-thread-selection-safe
data-testid={`sidebar-v2-settled-recency-${group.id}`}
className="list-none px-2.5 pb-0.5 pt-2 first:pt-1"
>
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/45">
{group.label}
</div>
</li>,
);
for (const thread of group.threads) {
items.push(renderThreadRow(thread, "settled"));
}
}
} else {
for (const thread of renderedSettledThreads) {
items.push(renderThreadRow(thread, "settled"));
}
}
return items;
})()}
Expand Down
Loading