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,