authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-26 01:34:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 01:34:42-07:00
log6f97b0f66a51fbafabd54a5616da8827ebdf6020
treec0e3e455e9aab61c5510eba374a301b7d18f89e8
parent8b873eab0d74b731bc0f387be84a8561eace789f
signature Signed by PGP key B5690EEEBB952194

perf(clients): sort projects and settled threads without re-parsing dates per comparison (#13759)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

7 files changed, 193 insertions(+), 108 deletions(-)

apps/mobile/src/features/threads/threadListV2.ts+2-5
......@@ -14,6 +14,7 @@ import {
1414 sortActiveThreadsByOrderKey,
1515 resolveSettledThreadTimestamp,
1616 sortPinnedThreadsByOrderKey,
17 sortSettledThreads,
1718} from "@t3tools/client-runtime/state/thread-sort";
1819import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1920
......@@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: {
606607 : orderedSnoozed.filter(
607608 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
608609 );
609 const orderedSettled = [...settled].sort(
610 (left, right) =>
611 parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -
612 parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),
613 );
610 const orderedSettled = sortSettledThreads(settled);
614611 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
615612 const pagedSettled =
616613 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
apps/web/src/components/Sidebar.logic.test.ts+48-73
......@@ -31,7 +31,6 @@ import {
3131 shouldClearThreadSelectionOnMouseDown,
3232 shouldRecedeSidebarThread,
3333 sortLogicalProjectsForSidebar,
34 sortSettledThreadsForSidebar,
3534 resolveSidebarDropTarget,
3635 pinOrderKeyBetween,
3736 planPinnedReorder,
......@@ -51,6 +50,7 @@ import {
5150 resolveSidebarDropVerb,
5251} from "./Sidebar.logic";
5352import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
53import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
5454import {
5555 EnvironmentId,
5656 OrchestrationLatestTurn,
......@@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => {
16901690 };
16911691 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
16921692 expect(preview).toEqual({ ...final, settledAt: now });
1693 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
1693 expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([
16941694 "dragged",
16951695 "newer",
16961696 ]);
1697 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(
1698 sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),
1697 expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual(
1698 sortSettledThreads([existing, final]).map((row) => row.id),
16991699 );
17001700 });
17011701
......@@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => {
17101710 const final = { ...source, snoozedAt: null, snoozedUntil: null };
17111711 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
17121712 expect(preview).toEqual(final);
1713 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
1713 expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([
17141714 "newer",
17151715 "dragged",
17161716 ]);
......@@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => {
18201820 });
18211821});
18221822
1823describe("sortSettledThreadsForSidebar", () => {
1824 const settled = (input: {
1825 id: string;
1826 settledAt?: string | null;
1827 latestUserMessageAt?: string | null;
1828 latestTurn?: OrchestrationLatestTurn | null;
1829 updatedAt?: string;
1830 }) => ({
1831 id: input.id,
1832 settledAt: input.settledAt ?? null,
1833 latestUserMessageAt: input.latestUserMessageAt ?? null,
1834 latestTurn: input.latestTurn ?? null,
1835 updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
1836 });
1837
1838 it("orders by settle time, most recently settled first", () => {
1839 const sorted = sortSettledThreadsForSidebar([
1840 settled({
1841 id: "settled-first",
1842 settledAt: "2026-03-09T10:00:00.000Z",
1843 // Created/active later than the other thread: settle time must win.
1844 latestUserMessageAt: "2026-03-09T09:59:00.000Z",
1845 }),
1846 settled({
1847 id: "settled-last",
1848 settledAt: "2026-03-09T12:00:00.000Z",
1849 latestUserMessageAt: "2026-03-09T08:00:00.000Z",
1850 }),
1851 ]);
1852
1853 expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]);
1854 });
1855
1856 it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
1857 const sorted = sortSettledThreadsForSidebar([
1858 settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
1859 settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
1860 settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
1861 ]);
1862
1863 expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]);
1864 });
1865
1866 it("counts a turn completion as activity for auto-settled threads", () => {
1867 // The message came in before the other thread's, but its turn finished
1868 // after: completion time is the real "work ended" moment.
1869 const sorted = sortSettledThreadsForSidebar([
1870 settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
1871 settled({
1872 id: "completed-later",
1873 latestUserMessageAt: "2026-03-09T10:00:00.000Z",
1874 latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }),
1875 }),
1876 ]);
1877
1878 expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
1879 });
1880
1881 it("breaks timestamp ties by id so the order is stable", () => {
1882 const sorted = sortSettledThreadsForSidebar([
1883 settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
1884 settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
1885 ]);
1886
1887 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
1888 });
1889});
1890
18911823describe("resolveWorkingStartedAt", () => {
18921824 const session = {
18931825 threadId: ThreadId.make("thread-1"),
......@@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => {
23752307 ]);
23762308 });
23772309
2310 it.each(["updated_at", "created_at"] as const)(
2311 "matches the per-comparison %s order on a shuffled list with ties",
2312 (sortOrder) => {
2313 const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`;
2314 // (index * 7) % 24 scrambles the input order. Titles repeat, and
2315 // projects 16-23 have no threads, so they use their own stamps.
2316 const projects = Array.from({ length: 24 }, (_, index) => {
2317 const n = (index * 7) % 24;
2318 return makeProject({
2319 id: ProjectId.make(`project-${n}`),
2320 title: n % 2 === 0 ? "Alpha" : "Beta",
2321 createdAt: minute(n % 3),
2322 updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2),
2323 });
2324 });
2325 const threads = Array.from({ length: 48 }, (_, n) => ({
2326 projectId: ProjectId.make(`project-${n % 16}`),
2327 createdAt: minute(n % 6),
2328 updatedAt: minute(n % 3),
2329 latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5),
2330 }));
2331 // The comparator this sort replaced: it walked each project's threads
2332 // on every call.
2333 const timestamp = (project: Project) =>
2334 getProjectSortTimestamp(
2335 project,
2336 threads.filter((thread) => thread.projectId === project.id),
2337 sortOrder,
2338 );
2339 const expected = projects.toSorted((left, right) => {
2340 const rightTimestamp = timestamp(right);
2341 const leftTimestamp = timestamp(left);
2342 const byTimestamp =
2343 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
2344 return (
2345 byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id)
2346 );
2347 });
2348
2349 expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected);
2350 },
2351 );
2352
23782353 it("returns the project timestamp when no threads are present", () => {
23792354 const timestamp = getProjectSortTimestamp(
23802355 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
apps/web/src/components/Sidebar.logic.ts+13-23
......@@ -16,10 +16,8 @@ import {
1616} from "@t3tools/client-runtime/state/thread-settled";
1717import {
1818 getThreadSortTimestamp,
19 resolveSettledThreadTimestamp,
2019 sortThreads,
2120 toSortableTimestamp,
22 type SettledThreadTimestampInput,
2321 type ThreadSortInput,
2422} from "../lib/threadSort";
2523import type { SidebarThreadSummary, Thread } from "../types";
......@@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState(
937935 }
938936}
939937
940// Settled rows are history, so they order by when the work ENDED, not when
941// the thread was created or last touched.
942export function sortSettledThreadsForSidebar<
943 T extends SettledThreadTimestampInput & { readonly id: string },
944>(threads: readonly T[]): T[] {
945 const timestampMs = (thread: T) => {
946 const timestamp = resolveSettledThreadTimestamp(thread);
947 return timestamp === null ? 0 : Date.parse(timestamp);
948 };
949 return [...threads].toSorted(
950 (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id),
951 );
952}
953
954938/** The timestamp a working thread's elapsed label counts from: the running
955939 turn's start (request time until adoption), falling back to the session's
956940 last transition when the turn projection lags behind. Malformed
......@@ -1136,13 +1120,19 @@ function sortProjectsByActivity<TProject extends SidebarProject>(
11361120 return [...projects];
11371121 }
11381122
1139 return [...projects].toSorted((left, right) => {
1140 const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder);
1141 const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder);
1142 const byTimestamp =
1143 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
1144 return byTimestamp || compareTies(left, right);
1145 });
1123 // Each project's timestamp walks all of its threads, so compute it once
1124 // per project instead of once per comparison.
1125 return projects
1126 .map((project) => ({
1127 project,
1128 timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder),
1129 }))
1130 .sort((left, right) => {
1131 const byTimestamp =
1132 right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1;
1133 return byTimestamp || compareTies(left.project, right.project);
1134 })
1135 .map(({ project }) => project);
11461136}
11471137
11481138export function sortProjectsForSidebar<
apps/web/src/components/Sidebar.tsx+6-4
......@@ -21,7 +21,10 @@ import {
2121 effectiveSnoozed,
2222 threadWokeAt,
2323} from "@t3tools/client-runtime/state/thread-settled";
24import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
24import {
25 resolveSettledThreadTimestamp,
26 sortSettledThreads,
27} from "@t3tools/client-runtime/state/thread-sort";
2528import {
2629 threadSearchMatchKey,
2730 type EnvironmentThreadSearchMatch,
......@@ -181,7 +184,6 @@ import {
181184 sidebarMarkerId,
182185 sortLogicalProjectsForSidebar,
183186 sortPinnedThreadsForSidebar,
184 sortSettledThreadsForSidebar,
185187 sortThreadsForSidebar,
186188 useRetainedValue,
187189 useSidebarRowSubscriptionLease,
......@@ -2626,7 +2628,7 @@ export default function Sidebar() {
26262628 firstValidTimestampMs(left.snoozedUntil ?? null) -
26272629 firstValidTimestampMs(right.snoozedUntil ?? null),
26282630 ),
2629 settledThreads: sortSettledThreadsForSidebar(settled),
2631 settledThreads: sortSettledThreads(settled),
26302632 snoozeNow: preciseNow,
26312633 };
26322634 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
......@@ -3452,7 +3454,7 @@ export default function Sidebar() {
34523454 if (dragState === null || thread === undefined) return [];
34533455 const key = (candidate: EnvironmentThreadShell) =>
34543456 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
3455 return sortSettledThreadsForSidebar([
3457 return sortSettledThreads([
34563458 ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
34573459 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
34583460 ]).map(key);
apps/web/src/lib/threadSort.ts-2
......@@ -1,9 +1,7 @@
11export {
22 getLatestThreadForProject,
33 getThreadSortTimestamp,
4 resolveSettledThreadTimestamp,
54 sortThreads,
65 toSortableTimestamp,
7 type SettledThreadTimestampInput,
86 type ThreadSortInput,
97} from "@t3tools/client-runtime/state/thread-sort";
packages/client-runtime/src/state/threadSort.test.ts+106-1
......@@ -1,4 +1,4 @@
1import { ProjectId } from "@t3tools/contracts";
1import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
......@@ -10,7 +10,9 @@ import {
1010 resolveSettledThreadTimestamp,
1111 sortActiveThreadsByOrderKey,
1212 sortPinnedThreadsByOrderKey,
13 sortSettledThreads,
1314 sortThreads,
15 type SettledThreadTimestampInput,
1416 type ThreadSortInput,
1517} from "./threadSort.ts";
1618
......@@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => {
5961 });
6062});
6163
64describe("sortSettledThreads", () => {
65 const settled = (input: {
66 id: string;
67 settledAt?: string | null;
68 latestUserMessageAt?: string | null;
69 latestTurn?: OrchestrationLatestTurn | null;
70 updatedAt?: string;
71 }) => ({
72 id: input.id,
73 settledAt: input.settledAt ?? null,
74 latestUserMessageAt: input.latestUserMessageAt ?? null,
75 latestTurn: input.latestTurn ?? null,
76 updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
77 });
78
79 it("orders by settle time, most recently settled first", () => {
80 const sorted = sortSettledThreads([
81 settled({
82 id: "settled-first",
83 settledAt: "2026-03-09T10:00:00.000Z",
84 // Created/active later than the other thread: settle time must win.
85 latestUserMessageAt: "2026-03-09T09:59:00.000Z",
86 }),
87 settled({
88 id: "settled-last",
89 settledAt: "2026-03-09T12:00:00.000Z",
90 latestUserMessageAt: "2026-03-09T08:00:00.000Z",
91 }),
92 ]);
93
94 expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]);
95 });
96
97 it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
98 const sorted = sortSettledThreads([
99 settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
100 settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
101 settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
102 ]);
103
104 expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]);
105 });
106
107 it("counts a turn completion as activity for auto-settled threads", () => {
108 // The message came in before the other thread's, but its turn finished
109 // after: completion time is the real "work ended" moment.
110 const sorted = sortSettledThreads([
111 settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
112 settled({
113 id: "completed-later",
114 latestUserMessageAt: "2026-03-09T10:00:00.000Z",
115 latestTurn: {
116 turnId: TurnId.make("turn-1"),
117 state: "completed",
118 assistantMessageId: null,
119 requestedAt: "2026-03-09T10:00:00.000Z",
120 startedAt: "2026-03-09T10:00:00.000Z",
121 completedAt: "2026-03-09T10:30:00.000Z",
122 },
123 }),
124 ]);
125
126 expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
127 });
128
129 it("breaks timestamp ties by id so the order is stable", () => {
130 const sorted = sortSettledThreads([
131 settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
132 settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
133 ]);
134
135 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
136 });
137
138 it("matches the per-comparison order on a shuffled list with ties", () => {
139 const stamps = [
140 { settledAt: "2026-03-09T10:00:00.000Z" },
141 { settledAt: "invalid", latestUserMessageAt: "2026-03-09T10:00:00.000Z" },
142 { latestUserMessageAt: "2026-03-09T11:00:00.000Z" },
143 { updatedAt: "2026-03-09T09:00:00.000Z" },
144 { updatedAt: "invalid" },
145 ];
146 // Ids repeat every 3 rows and stamps every 5, so rows tie on the time,
147 // on the id, and on both. (index * 7) % 30 scrambles the input order.
148 const threads = Array.from({ length: 30 }, (_, index) => {
149 const row = (index * 7) % 30;
150 return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row };
151 });
152 // The comparator this sort replaced: it resolved both keys on every call.
153 const timestampMs = (thread: SettledThreadTimestampInput) => {
154 const timestamp = resolveSettledThreadTimestamp(thread);
155 return timestamp === null ? 0 : Date.parse(timestamp);
156 };
157 const expected = threads.toSorted(
158 (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id),
159 );
160
161 expect(sortSettledThreads(threads).map((thread) => thread.row)).toEqual(
162 expected.map((thread) => thread.row),
163 );
164 });
165});
166
62167describe("sortThreads", () => {
63168 it.each(["created_at", "updated_at"] as const)(
64169 "preserves references, input order and descending id ties for %s",
packages/client-runtime/src/state/threadSort.ts+18
......@@ -47,6 +47,24 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu
4747 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
4848}
4949
50/** Settled rows are history, so they order by when the work ENDED, newest
51 first, with an id tiebreak. Each key resolves once per sort, not once
52 per comparison. Shared by web and mobile so both render the same order. */
53export function sortSettledThreads<T extends SettledThreadTimestampInput & { readonly id: string }>(
54 threads: readonly T[],
55): T[] {
56 return threads
57 .map((thread) => {
58 const timestamp = resolveSettledThreadTimestamp(thread);
59 return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) };
60 })
61 .sort(
62 (left, right) =>
63 right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id),
64 )
65 .map(({ thread }) => thread);
66}
67
5068function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
5169 for (const value of values) {
5270 const timestamp = toSortableTimestamp(value ?? undefined);