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 {...@@ -14,6 +14,7 @@ import {
14 sortActiveThreadsByOrderKey,14 sortActiveThreadsByOrderKey,
15 resolveSettledThreadTimestamp,15 resolveSettledThreadTimestamp,
16 sortPinnedThreadsByOrderKey,16 sortPinnedThreadsByOrderKey,
17 sortSettledThreads,
17} from "@t3tools/client-runtime/state/thread-sort";18} from "@t3tools/client-runtime/state/thread-sort";
18import type { EnvironmentId, ProjectId } from "@t3tools/contracts";19import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1920
...@@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: {...@@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: {
606 : orderedSnoozed.filter(607 : orderedSnoozed.filter(
607 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,608 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
608 );609 );
609 const orderedSettled = [...settled].sort(610 const orderedSettled = sortSettledThreads(settled);
610 (left, right) =>
611 parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -
612 parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),
613 );
614 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;611 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
615 const pagedSettled =612 const pagedSettled =
616 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;613 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
apps/web/src/components/Sidebar.logic.test.ts+48-73
...@@ -31,7 +31,6 @@ import {...@@ -31,7 +31,6 @@ import {
31 shouldClearThreadSelectionOnMouseDown,31 shouldClearThreadSelectionOnMouseDown,
32 shouldRecedeSidebarThread,32 shouldRecedeSidebarThread,
33 sortLogicalProjectsForSidebar,33 sortLogicalProjectsForSidebar,
34 sortSettledThreadsForSidebar,
35 resolveSidebarDropTarget,34 resolveSidebarDropTarget,
36 pinOrderKeyBetween,35 pinOrderKeyBetween,
37 planPinnedReorder,36 planPinnedReorder,
...@@ -51,6 +50,7 @@ import {...@@ -51,6 +50,7 @@ import {
51 resolveSidebarDropVerb,50 resolveSidebarDropVerb,
52} from "./Sidebar.logic";51} from "./Sidebar.logic";
53import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";52import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
53import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
54import {54import {
55 EnvironmentId,55 EnvironmentId,
56 OrchestrationLatestTurn,56 OrchestrationLatestTurn,
...@@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => {...@@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => {
1690 };1690 };
1691 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };1691 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
1692 expect(preview).toEqual({ ...final, settledAt: now });1692 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([
1694 "dragged",1694 "dragged",
1695 "newer",1695 "newer",
1696 ]);1696 ]);
1697 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(1697 expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual(
1698 sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),1698 sortSettledThreads([existing, final]).map((row) => row.id),
1699 );1699 );
1700 });1700 });
17011701
...@@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => {...@@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => {
1710 const final = { ...source, snoozedAt: null, snoozedUntil: null };1710 const final = { ...source, snoozedAt: null, snoozedUntil: null };
1711 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };1711 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
1712 expect(preview).toEqual(final);1712 expect(preview).toEqual(final);
1713 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([1713 expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([
1714 "newer",1714 "newer",
1715 "dragged",1715 "dragged",
1716 ]);1716 ]);
...@@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => {...@@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => {
1820 });1820 });
1821});1821});
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
1891describe("resolveWorkingStartedAt", () => {1823describe("resolveWorkingStartedAt", () => {
1892 const session = {1824 const session = {
1893 threadId: ThreadId.make("thread-1"),1825 threadId: ThreadId.make("thread-1"),
...@@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => {...@@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => {
2375 ]);2307 ]);
2376 });2308 });
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
2378 it("returns the project timestamp when no threads are present", () => {2353 it("returns the project timestamp when no threads are present", () => {
2379 const timestamp = getProjectSortTimestamp(2354 const timestamp = getProjectSortTimestamp(
2380 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),2355 makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
apps/web/src/components/Sidebar.logic.ts+13-23
...@@ -16,10 +16,8 @@ import {...@@ -16,10 +16,8 @@ import {
16} from "@t3tools/client-runtime/state/thread-settled";16} from "@t3tools/client-runtime/state/thread-settled";
17import {17import {
18 getThreadSortTimestamp,18 getThreadSortTimestamp,
19 resolveSettledThreadTimestamp,
20 sortThreads,19 sortThreads,
21 toSortableTimestamp,20 toSortableTimestamp,
22 type SettledThreadTimestampInput,
23 type ThreadSortInput,21 type ThreadSortInput,
24} from "../lib/threadSort";22} from "../lib/threadSort";
25import type { SidebarThreadSummary, Thread } from "../types";23import type { SidebarThreadSummary, Thread } from "../types";
...@@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState(...@@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState(
937 }935 }
938}936}
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
954/** The timestamp a working thread's elapsed label counts from: the running938/** The timestamp a working thread's elapsed label counts from: the running
955 turn's start (request time until adoption), falling back to the session's939 turn's start (request time until adoption), falling back to the session's
956 last transition when the turn projection lags behind. Malformed940 last transition when the turn projection lags behind. Malformed
...@@ -1136,13 +1120,19 @@ function sortProjectsByActivity<TProject extends SidebarProject>(...@@ -1136,13 +1120,19 @@ function sortProjectsByActivity<TProject extends SidebarProject>(
1136 return [...projects];1120 return [...projects];
1137 }1121 }
11381122
1139 return [...projects].toSorted((left, right) => {1123 // Each project's timestamp walks all of its threads, so compute it once
1140 const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder);1124 // per project instead of once per comparison.
1141 const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder);1125 return projects
1142 const byTimestamp =1126 .map((project) => ({
1143 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;1127 project,
1144 return byTimestamp || compareTies(left, right);1128 timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder),
1145 });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);
1146}1136}
11471137
1148export function sortProjectsForSidebar<1138export function sortProjectsForSidebar<
apps/web/src/components/Sidebar.tsx+6-4
...@@ -21,7 +21,10 @@ import {...@@ -21,7 +21,10 @@ import {
21 effectiveSnoozed,21 effectiveSnoozed,
22 threadWokeAt,22 threadWokeAt,
23} from "@t3tools/client-runtime/state/thread-settled";23} 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";
25import {28import {
26 threadSearchMatchKey,29 threadSearchMatchKey,
27 type EnvironmentThreadSearchMatch,30 type EnvironmentThreadSearchMatch,
...@@ -181,7 +184,6 @@ import {...@@ -181,7 +184,6 @@ import {
181 sidebarMarkerId,184 sidebarMarkerId,
182 sortLogicalProjectsForSidebar,185 sortLogicalProjectsForSidebar,
183 sortPinnedThreadsForSidebar,186 sortPinnedThreadsForSidebar,
184 sortSettledThreadsForSidebar,
185 sortThreadsForSidebar,187 sortThreadsForSidebar,
186 useRetainedValue,188 useRetainedValue,
187 useSidebarRowSubscriptionLease,189 useSidebarRowSubscriptionLease,
...@@ -2626,7 +2628,7 @@ export default function Sidebar() {...@@ -2626,7 +2628,7 @@ export default function Sidebar() {
2626 firstValidTimestampMs(left.snoozedUntil ?? null) -2628 firstValidTimestampMs(left.snoozedUntil ?? null) -
2627 firstValidTimestampMs(right.snoozedUntil ?? null),2629 firstValidTimestampMs(right.snoozedUntil ?? null),
2628 ),2630 ),
2629 settledThreads: sortSettledThreadsForSidebar(settled),2631 settledThreads: sortSettledThreads(settled),
2630 snoozeNow: preciseNow,2632 snoozeNow: preciseNow,
2631 };2633 };
2632 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);2634 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
...@@ -3452,7 +3454,7 @@ export default function Sidebar() {...@@ -3452,7 +3454,7 @@ export default function Sidebar() {
3452 if (dragState === null || thread === undefined) return [];3454 if (dragState === null || thread === undefined) return [];
3453 const key = (candidate: EnvironmentThreadShell) =>3455 const key = (candidate: EnvironmentThreadShell) =>
3454 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));3456 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
3455 return sortSettledThreadsForSidebar([3457 return sortSettledThreads([
3456 ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),3458 ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
3457 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),3459 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
3458 ]).map(key);3460 ]).map(key);
apps/web/src/lib/threadSort.ts-2
...@@ -1,9 +1,7 @@...@@ -1,9 +1,7 @@
1export {1export {
2 getLatestThreadForProject,2 getLatestThreadForProject,
3 getThreadSortTimestamp,3 getThreadSortTimestamp,
4 resolveSettledThreadTimestamp,
5 sortThreads,4 sortThreads,
6 toSortableTimestamp,5 toSortableTimestamp,
7 type SettledThreadTimestampInput,
8 type ThreadSortInput,6 type ThreadSortInput,
9} from "@t3tools/client-runtime/state/thread-sort";7} from "@t3tools/client-runtime/state/thread-sort";
packages/client-runtime/src/state/threadSort.test.ts+106-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ProjectId } from "@t3tools/contracts";1import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
...@@ -10,7 +10,9 @@ import {...@@ -10,7 +10,9 @@ import {
10 resolveSettledThreadTimestamp,10 resolveSettledThreadTimestamp,
11 sortActiveThreadsByOrderKey,11 sortActiveThreadsByOrderKey,
12 sortPinnedThreadsByOrderKey,12 sortPinnedThreadsByOrderKey,
13 sortSettledThreads,
13 sortThreads,14 sortThreads,
15 type SettledThreadTimestampInput,
14 type ThreadSortInput,16 type ThreadSortInput,
15} from "./threadSort.ts";17} from "./threadSort.ts";
1618
...@@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => {...@@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => {
59 });61 });
60});62});
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
62describe("sortThreads", () => {167describe("sortThreads", () => {
63 it.each(["created_at", "updated_at"] as const)(168 it.each(["created_at", "updated_at"] as const)(
64 "preserves references, input order and descending id ties for %s",169 "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...@@ -47,6 +47,24 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu
47 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;47 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
48}48}
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
50function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {68function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
51 for (const value of values) {69 for (const value of values) {
52 const timestamp = toSortableTimestamp(value ?? undefined);70 const timestamp = toSortableTimestamp(value ?? undefined);