From ea49c2f03b2bd6da9a512e522608fa6747fe09c9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 22:15:03 -0700 Subject: [PATCH] perf(clients): sort projects and settled threads without re-parsing dates per comparison The project sort called getProjectSortTimestamp inside its comparator, so every comparison walked all of a project's threads and parsed their dates again (about 5 ms per shell write at 5k threads). The settled sort also resolved and parsed both timestamps on every comparison, on web and mobile. Compute each project's timestamp and each settled thread's timestamp once, then sort by the key. The comparisons and tie-breaks do not change, so the order is the same. The keyed settled sort moves to client-runtime as sortSettledThreads, so web and mobile share it. Mobile now also breaks settled timestamp ties by id, like web. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/threads/threadListV2.ts | 7 +- apps/web/src/components/Sidebar.logic.test.ts | 121 +++++++----------- apps/web/src/components/Sidebar.logic.ts | 36 ++---- apps/web/src/components/Sidebar.tsx | 10 +- apps/web/src/lib/threadSort.ts | 2 - .../src/state/threadSort.test.ts | 107 +++++++++++++++- .../client-runtime/src/state/threadSort.ts | 18 +++ 7 files changed, 193 insertions(+), 108 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 9a876c899ac4..8c4dd0b0f097 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -14,6 +14,7 @@ import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, + sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: { : orderedSnoozed.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); - const orderedSettled = [...settled].sort( - (left, right) => - parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), - ); + const orderedSettled = sortSettledThreads(settled); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = 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 30044e641137..b4b07805596d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -31,7 +31,6 @@ import { shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebar, resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, @@ -51,6 +50,7 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { EnvironmentId, OrchestrationLatestTurn, @@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => { }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual({ ...final, settledAt: now }); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "dragged", "newer", ]); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual( - sortSettledThreadsForSidebar([existing, final]).map((row) => row.id), + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual( + sortSettledThreads([existing, final]).map((row) => row.id), ); }); @@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => { const final = { ...source, snoozedAt: null, snoozedUntil: null }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual(final); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "newer", "dragged", ]); @@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); -describe("sortSettledThreadsForSidebar", () => { - 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 = sortSettledThreadsForSidebar([ - 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 = sortSettledThreadsForSidebar([ - 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 = sortSettledThreadsForSidebar([ - 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 = sortSettledThreadsForSidebar([ - 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"), @@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => { ]); }); + it.each(["updated_at", "created_at"] as const)( + "matches the per-comparison %s order on a shuffled list with ties", + (sortOrder) => { + const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`; + // (index * 7) % 24 scrambles the input order. Titles repeat, and + // projects 16-23 have no threads, so they use their own stamps. + const projects = Array.from({ length: 24 }, (_, index) => { + const n = (index * 7) % 24; + return makeProject({ + id: ProjectId.make(`project-${n}`), + title: n % 2 === 0 ? "Alpha" : "Beta", + createdAt: minute(n % 3), + updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2), + }); + }); + const threads = Array.from({ length: 48 }, (_, n) => ({ + projectId: ProjectId.make(`project-${n % 16}`), + createdAt: minute(n % 6), + updatedAt: minute(n % 3), + latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5), + })); + // The comparator this sort replaced: it walked each project's threads + // on every call. + const timestamp = (project: Project) => + getProjectSortTimestamp( + project, + threads.filter((thread) => thread.projectId === project.id), + sortOrder, + ); + const expected = projects.toSorted((left, right) => { + const rightTimestamp = timestamp(right); + const leftTimestamp = timestamp(left); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + return ( + byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id) + ); + }); + + expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected); + }, + ); + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f4b13a5000bc..3e12090f3228 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,10 +16,8 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { getThreadSortTimestamp, - resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, - type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; @@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState( } } -// Settled rows are history, so they order by when the work ENDED, not when -// the thread was created or last touched. -export function sortSettledThreadsForSidebar< - T extends SettledThreadTimestampInput & { readonly id: string }, ->(threads: readonly T[]): T[] { - const timestampMs = (thread: T) => { - const timestamp = resolveSettledThreadTimestamp(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 @@ -1136,13 +1120,19 @@ function sortProjectsByActivity( return [...projects]; } - return [...projects].toSorted((left, right) => { - const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder); - const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - return byTimestamp || compareTies(left, right); - }); + // Each project's timestamp walks all of its threads, so compute it once + // per project instead of once per comparison. + return projects + .map((project) => ({ + project, + timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder), + })) + .sort((left, right) => { + const byTimestamp = + right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1; + return byTimestamp || compareTies(left.project, right.project); + }) + .map(({ project }) => project); } export function sortProjectsForSidebar< diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 968fd0a5f310..4926df4948ae 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,7 +21,10 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + resolveSettledThreadTimestamp, + sortSettledThreads, +} from "@t3tools/client-runtime/state/thread-sort"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, @@ -181,7 +184,6 @@ import { sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, - sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, @@ -2626,7 +2628,7 @@ export default function Sidebar() { firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), - settledThreads: sortSettledThreadsForSidebar(settled), + settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); @@ -3452,7 +3454,7 @@ export default function Sidebar() { if (dragState === null || thread === undefined) return []; const key = (candidate: EnvironmentThreadShell) => scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); - return sortSettledThreadsForSidebar([ + return sortSettledThreads([ ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), applySidebarThreadDrop(thread, "settled", dragState.occurredAt), ]).map(key); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 7785bceaac73..ac3dea3aca5d 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,9 +1,7 @@ export { getLatestThreadForProject, getThreadSortTimestamp, - resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, - type SettledThreadTimestampInput, type ThreadSortInput, } from "@t3tools/client-runtime/state/thread-sort"; diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 0e1fba746cf3..9fe8df570499 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -10,7 +10,9 @@ import { resolveSettledThreadTimestamp, sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, + sortSettledThreads, sortThreads, + type SettledThreadTimestampInput, type ThreadSortInput, } from "./threadSort.ts"; @@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => { }); }); +describe("sortSettledThreads", () => { + 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 = sortSettledThreads([ + 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 = sortSettledThreads([ + 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 = sortSettledThreads([ + settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), + settled({ + id: "completed-later", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: "2026-03-09T10:00:00.000Z", + 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 = sortSettledThreads([ + 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"]); + }); + + it("matches the per-comparison order on a shuffled list with ties", () => { + const stamps = [ + { settledAt: "2026-03-09T10:00:00.000Z" }, + { settledAt: "invalid", latestUserMessageAt: "2026-03-09T10:00:00.000Z" }, + { latestUserMessageAt: "2026-03-09T11:00:00.000Z" }, + { updatedAt: "2026-03-09T09:00:00.000Z" }, + { updatedAt: "invalid" }, + ]; + // Ids repeat every 3 rows and stamps every 5, so rows tie on the time, + // on the id, and on both. (index * 7) % 30 scrambles the input order. + const threads = Array.from({ length: 30 }, (_, index) => { + const row = (index * 7) % 30; + return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row }; + }); + // The comparator this sort replaced: it resolved both keys on every call. + const timestampMs = (thread: SettledThreadTimestampInput) => { + const timestamp = resolveSettledThreadTimestamp(thread); + return timestamp === null ? 0 : Date.parse(timestamp); + }; + const expected = threads.toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); + + expect(sortSettledThreads(threads).map((thread) => thread.row)).toEqual( + expected.map((thread) => thread.row), + ); + }); +}); + describe("sortThreads", () => { it.each(["created_at", "updated_at"] as const)( "preserves references, input order and descending id ties for %s", diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 93878310839f..b6a7264f3052 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -47,6 +47,24 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; } +/** Settled rows are history, so they order by when the work ENDED, newest + first, with an id tiebreak. Each key resolves once per sort, not once + per comparison. Shared by web and mobile so both render the same order. */ +export function sortSettledThreads( + threads: readonly T[], +): T[] { + return threads + .map((thread) => { + const timestamp = resolveSettledThreadTimestamp(thread); + return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) }; + }) + .sort( + (left, right) => + right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id), + ) + .map(({ thread }) => thread); +} + function getFirstSortableTimestamp(...values: Array): number | null { for (const value of values) { const timestamp = toSortableTimestamp(value ?? undefined);