From 816ff2d13ef1cf443791c1330e0dc7691f76b2b2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 31 Jul 2026 14:07:29 -0700 Subject: [PATCH] refactor(clients): share thread list v2 sort in client-runtime Web and mobile each carried their own copy of the v2 active-list priority derivation and settled-tail ordering, and mobile's settled tail ignored settledAt entirely, so the two clients could disagree on order. The priority rule (threadListV2Priority) and the settled sort (sortSettledThreadsForListV2 + resolveSettledTimestamp) now live in @t3tools/client-runtime/state/thread-sort next to sortThreadsForListV2. Priority derives only from server-backed state (wokeAt, settledOverride), so every client and device renders the same order by construction. Mobile's settled rows also adopt the shared timestamp resolver for their time label so label and order can't disagree. Co-Authored-By: Claude Fable 5 --- .../features/threads/thread-list-v2-items.tsx | 5 +- .../src/features/threads/threadListV2.test.ts | 4 +- .../src/features/threads/threadListV2.ts | 51 ++++------- apps/web/src/components/Sidebar.logic.test.ts | 69 --------------- apps/web/src/components/Sidebar.logic.ts | 45 ---------- apps/web/src/components/SidebarV2.tsx | 29 ++++--- .../src/state/threadSort.test.ts | 87 +++++++++++++++++++ .../client-runtime/src/state/threadSort.ts | 66 ++++++++++++++ 8 files changed, 196 insertions(+), 160 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6af5795a94a..c6fb032f028 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,6 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { resolveSettledTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -555,7 +556,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} style={{ fontFamily: MONO_FONT }} > - {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + {/* Settled rows read "how long ago did this wrap up", matching + their sort key so label and order can't disagree. */} + {relativeTime(resolveSettledTimestamp(thread) ?? thread.createdAt)} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index d3b7fb7ee34..5f1480d5326 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -360,7 +360,9 @@ describe("buildThreadListV2Items settled paging", () => { id: ThreadId.make(`settled-${index}`), title: `Settled ${index}`, settledOverride: "settled", - settledAt: NOW, + // Explicit settle stamps order the tail (shared resolver): most + // recently settled first. + settledAt: `2026-06-01T1${index}:00:00.000Z`, latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, // A turn adopted the message (same requestedAt): without it the // thread reads as a queued turn start, which never settles. diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 9505595eed1..898425e3632 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -3,7 +3,11 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { sortThreadsForListV2 } from "@t3tools/client-runtime/state/thread-sort"; +import { + sortSettledThreadsForListV2, + sortThreadsForListV2, + threadListV2Priority, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -71,17 +75,6 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** First VALID timestamp wins: a present-yet-malformed string falls through - to the next candidate rather than sinking the row to the epoch. */ -function firstValidTimestampMs(...candidates: ReadonlyArray): number { - for (const candidate of candidates) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed)) return parsed; - } - return 0; -} - export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -203,7 +196,7 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - const wokeThreadKeys = new Set(); + const wokeAtByThreadKey = new Map(); let snoozedCount = 0; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { @@ -231,11 +224,12 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; const wokeAt = supportsSnooze ? threadWokeAt(thread, { now: snoozeNow }) : null; if (wokeAt !== null) { - wokeThreadKeys.add( + wokeAtByThreadKey.set( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id, }), + wokeAt, ); } // Visibility parity with web: a snoozed thread leaves the list until it @@ -267,25 +261,18 @@ export function buildThreadListV2Items(input: { } } - const orderedActive = sortThreadsForListV2(active, (thread) => { - if ( - wokeThreadKeys.has( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ) - ) { - return "woke"; - } - if (thread.settledOverride === "active") return "unsettled"; - return "default"; - }); - const orderedSettled = [...settled].sort( - (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + const orderedActive = sortThreadsForListV2(active, (thread) => + threadListV2Priority(thread, { + wokeAt: + wokeAtByThreadKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) ?? null, + }), ); + const orderedSettled = sortSettledThreadsForListV2(settled); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const visibleSettled = orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 24989593173..d45d2656d5e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -25,7 +25,6 @@ import { shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -711,74 +710,6 @@ describe("isSidebarV2ThreadWoke", () => { }); }); -describe("sortSettledThreadsForSidebarV2", () => { - const settled = (input: { - id: string; - settledAt?: string | null; - latestUserMessageAt?: string | null; - latestTurn?: OrchestrationLatestTurn | null; - updatedAt?: string; - }) => ({ - id: input.id, - settledAt: input.settledAt ?? null, - latestUserMessageAt: input.latestUserMessageAt ?? null, - latestTurn: input.latestTurn ?? null, - updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", - }); - - it("orders by settle time, most recently settled first", () => { - const sorted = sortSettledThreadsForSidebarV2([ - settled({ - id: "settled-first", - settledAt: "2026-03-09T10:00:00.000Z", - // Created/active later than the other thread: settle time must win. - latestUserMessageAt: "2026-03-09T09:59:00.000Z", - }), - settled({ - id: "settled-last", - settledAt: "2026-03-09T12:00:00.000Z", - latestUserMessageAt: "2026-03-09T08:00:00.000Z", - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); - }); - - it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebarV2([ - settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), - settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); - }); - - it("counts a turn completion as activity for auto-settled threads", () => { - // The message came in before the other thread's, but its turn finished - // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebarV2([ - settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), - settled({ - id: "completed-later", - latestUserMessageAt: "2026-03-09T10:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); - }); - - it("breaks timestamp ties by id so the order is stable", () => { - const sorted = sortSettledThreadsForSidebarV2([ - settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); - }); -}); - 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 968837535ca..6ef2bbdbd9b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -490,51 +490,6 @@ export function isSidebarV2ThreadWoke(input: { return Number.isNaN(lastVisitedAtMs) || lastVisitedAtMs < wokeAtMs; } -type SettledTimestampInput = Pick< - SidebarThreadSummary, - "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" ->; - -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ -export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { - const settledAt = firstValidTimestamp(thread.settledAt); - if (settledAt !== null) return settledAt; - let latest: string | null = null; - let latestMs = Number.NEGATIVE_INFINITY; - for (const candidate of [ - thread.latestUserMessageAt, - thread.latestTurn?.requestedAt, - thread.latestTurn?.startedAt, - thread.latestTurn?.completedAt, - ]) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed) && parsed > latestMs) { - latest = candidate; - latestMs = parsed; - } - } - return latest ?? firstValidTimestamp(thread.updatedAt); -} - -// Settled rows are history, so they order by when the work ENDED, not when -// the thread was created or last touched. -export function sortSettledThreadsForSidebarV2< - T extends SettledTimestampInput & { readonly id: string }, ->(threads: readonly T[]): T[] { - const timestampMs = (thread: T) => { - const timestamp = resolveSettledTimestamp(thread); - return timestamp === null ? 0 : Date.parse(timestamp); - }; - return [...threads].toSorted( - (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), - ); -} - /** 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 c997ba57c50..118b5140406 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -6,7 +6,12 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { sortThreadsForListV2 } from "@t3tools/client-runtime/state/thread-sort"; +import { + resolveSettledTimestamp, + sortSettledThreadsForListV2, + sortThreadsForListV2, + threadListV2Priority, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -115,12 +120,10 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, - resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -1473,7 +1476,7 @@ export default function SidebarV2() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - const wokeThreadKeys = new Set(); + const wokeAtByThreadKey = new Map(); for (const thread of visible) { // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user @@ -1486,7 +1489,7 @@ export default function SidebarV2() { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; const wokeAt = supportsSnooze ? threadWokeAt(thread, { now: preciseNow }) : null; - if (wokeAt !== null) wokeThreadKeys.add(threadKey); + if (wokeAt !== null) wokeAtByThreadKey.set(threadKey, wokeAt); // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). @@ -1507,19 +1510,21 @@ export default function SidebarV2() { } } return { - activeThreads: sortThreadsForListV2(active, (thread) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (wokeThreadKeys.has(threadKey)) return "woke"; - if (thread.settledOverride === "active") return "unsettled"; - return "default"; - }), + activeThreads: sortThreadsForListV2(active, (thread) => + threadListV2Priority(thread, { + wokeAt: + wokeAtByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) ?? null, + }), + ), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), - settledThreads: sortSettledThreadsForSidebarV2(settled), + settledThreads: sortSettledThreadsForListV2(settled), snoozeNow: preciseNow, }; }, [ diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 4e8a10d7923..48ad51d7a71 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { + sortSettledThreadsForListV2, sortThreads, sortThreadsForListV2, + threadListV2Priority, type ThreadListV2Priority, type ThreadSortInput, } from "./threadSort.ts"; @@ -113,3 +115,88 @@ describe("sortThreadsForListV2", () => { expect(sortThreadsForListV2(tied).map((thread) => thread.id)).toEqual(["a", "b"]); }); }); + +describe("threadListV2Priority", () => { + it("ranks a wake above the manual keep-active pin", () => { + expect( + threadListV2Priority({ settledOverride: "active" }, { wokeAt: "2026-06-01T10:00:00.000Z" }), + ).toBe("woke"); + expect(threadListV2Priority({ settledOverride: "active" }, { wokeAt: null })).toBe("unsettled"); + expect(threadListV2Priority({ settledOverride: null }, { wokeAt: null })).toBe("default"); + }); +}); + +describe("sortSettledThreadsForListV2", () => { + const makeLatestTurn = (overrides?: { completedAt?: string | null }) => ({ + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: "2026-03-09T10:00:00.000Z", + completedAt: + overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z", + }); + + const settled = (input: { + id: string; + settledAt?: string | null; + latestUserMessageAt?: string | null; + latestTurn?: ReturnType | null; + updatedAt?: string; + }) => ({ + id: input.id, + settledAt: input.settledAt ?? null, + latestUserMessageAt: input.latestUserMessageAt ?? null, + latestTurn: input.latestTurn ?? null, + updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", + }); + + it("orders by settle time, most recently settled first", () => { + const sorted = sortSettledThreadsForListV2([ + settled({ + id: "settled-first", + settledAt: "2026-03-09T10:00:00.000Z", + // Created/active later than the other thread: settle time must win. + latestUserMessageAt: "2026-03-09T09:59:00.000Z", + }), + settled({ + id: "settled-last", + settledAt: "2026-03-09T12:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); + }); + + it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { + const sorted = sortSettledThreadsForListV2([ + settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); + }); + + it("counts a turn completion as activity for auto-settled threads", () => { + // The message came in before the other thread's, but its turn finished + // after: completion time is the real "work ended" moment. + const sorted = sortSettledThreadsForListV2([ + settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), + settled({ + id: "completed-later", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); + }); + + it("breaks timestamp ties by id so the order is stable", () => { + const sorted = sortSettledThreadsForListV2([ + settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 8a2fbc5c5fa..d832c9b66ed 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -21,6 +21,18 @@ const THREAD_LIST_V2_PRIORITY_RANK: Record = { default: 2, }; +/** Priority from server-backed state only: every client (and every device) + must derive the same order, so per-client data like lastVisitedAt cannot + feed it. A wake outranks the manual keep-active pin. */ +export function threadListV2Priority( + thread: { readonly settledOverride: "settled" | "active" | null }, + options: { readonly wokeAt: string | null }, +): ThreadListV2Priority { + if (options.wokeAt !== null) return "woke"; + if (thread.settledOverride === "active") return "unsettled"; + return "default"; +} + export function toSortableTimestamp(iso: string | undefined): number | null { if (!iso) return null; const ms = Date.parse(iso); @@ -84,6 +96,60 @@ export function sortThreadsForListV2 latestMs) { + latest = candidate; + latestMs = parsed; + } + } + if (latest !== null) return latest; + return toSortableTimestamp(thread.updatedAt) !== null ? thread.updatedAt : null; +} + +// Settled rows are history, so they order by when the work ENDED, not when +// the thread was created or last touched. +export function sortSettledThreadsForListV2( + threads: readonly T[], +): T[] { + const timestampMs = (thread: T) => { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? 0 : (toSortableTimestamp(timestamp) ?? 0); + }; + // Hermes does not ship the ES2023 change-by-copy array methods. + return [...threads].sort( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); +} + export function getThreadSortTimestamp( thread: ThreadSortInput, sortOrder: SidebarThreadSortOrder | Exclude,