| author | |
| committer | |
| log | 6f97b0f66a51fbafabd54a5616da8827ebdf6020 |
| tree | c0e3e455e9aab61c5510eba374a301b7d18f89e8 |
| parent | 8b873eab0d74b731bc0f387be84a8561eace789f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 14 | sortActiveThreadsByOrderKey, |
| 15 | 15 | resolveSettledThreadTimestamp, |
| 16 | 16 | sortPinnedThreadsByOrderKey, |
| 17 | sortSettledThreads, | |
| 17 | 18 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 18 | 19 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 19 | 20 | |
| ... | ... | @@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: { |
| 606 | 607 | : orderedSnoozed.filter( |
| 607 | 608 | (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, |
| 608 | 609 | ); |
| 609 | const orderedSettled = [...settled].sort( | |
| 610 | (left, right) => | |
| 611 | parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - | |
| 612 | parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), | |
| 613 | ); | |
| 610 | const orderedSettled = sortSettledThreads(settled); | |
| 614 | 611 | const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; |
| 615 | 612 | const pagedSettled = |
| 616 | 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 | 31 | shouldClearThreadSelectionOnMouseDown, |
| 32 | 32 | shouldRecedeSidebarThread, |
| 33 | 33 | sortLogicalProjectsForSidebar, |
| 34 | sortSettledThreadsForSidebar, | |
| 35 | 34 | resolveSidebarDropTarget, |
| 36 | 35 | pinOrderKeyBetween, |
| 37 | 36 | planPinnedReorder, |
| ... | ... | @@ -51,6 +50,7 @@ import { |
| 51 | 50 | resolveSidebarDropVerb, |
| 52 | 51 | } from "./Sidebar.logic"; |
| 53 | 52 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 53 | import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; | |
| 54 | 54 | import { |
| 55 | 55 | EnvironmentId, |
| 56 | 56 | OrchestrationLatestTurn, |
| ... | ... | @@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => { |
| 1690 | 1690 | }; |
| 1691 | 1691 | const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; |
| 1692 | 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 | 1694 | "dragged", |
| 1695 | 1695 | "newer", |
| 1696 | 1696 | ]); |
| 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), | |
| 1699 | 1699 | ); |
| 1700 | 1700 | }); |
| 1701 | 1701 | |
| ... | ... | @@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => { |
| 1710 | 1710 | const final = { ...source, snoozedAt: null, snoozedUntil: null }; |
| 1711 | 1711 | const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; |
| 1712 | 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 | 1714 | "newer", |
| 1715 | 1715 | "dragged", |
| 1716 | 1716 | ]); |
| ... | ... | @@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => { |
| 1820 | 1820 | }); |
| 1821 | 1821 | }); |
| 1822 | 1822 | |
| 1823 | describe("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 | ||
| 1891 | 1823 | describe("resolveWorkingStartedAt", () => { |
| 1892 | 1824 | const session = { |
| 1893 | 1825 | threadId: ThreadId.make("thread-1"), |
| ... | ... | @@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => { |
| 2375 | 2307 | ]); |
| 2376 | 2308 | }); |
| 2377 | 2309 | |
| 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 | 2353 | it("returns the project timestamp when no threads are present", () => { |
| 2379 | 2354 | const timestamp = getProjectSortTimestamp( |
| 2380 | 2355 | makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), |
apps/web/src/components/Sidebar.logic.ts+13-23| ... | ... | @@ -16,10 +16,8 @@ import { |
| 16 | 16 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 17 | 17 | import { |
| 18 | 18 | getThreadSortTimestamp, |
| 19 | resolveSettledThreadTimestamp, | |
| 20 | 19 | sortThreads, |
| 21 | 20 | toSortableTimestamp, |
| 22 | type SettledThreadTimestampInput, | |
| 23 | 21 | type ThreadSortInput, |
| 24 | 22 | } from "../lib/threadSort"; |
| 25 | 23 | import type { SidebarThreadSummary, Thread } from "../types"; |
| ... | ... | @@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState( |
| 937 | 935 | } |
| 938 | 936 | } |
| 939 | 937 | |
| 940 | // Settled rows are history, so they order by when the work ENDED, not when | |
| 941 | // the thread was created or last touched. | |
| 942 | export 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 | 938 | /** The timestamp a working thread's elapsed label counts from: the running |
| 955 | 939 | turn's start (request time until adoption), falling back to the session's |
| 956 | 940 | last transition when the turn projection lags behind. Malformed |
| ... | ... | @@ -1136,13 +1120,19 @@ function sortProjectsByActivity<TProject extends SidebarProject>( |
| 1136 | 1120 | return [...projects]; |
| 1137 | 1121 | } |
| 1138 | 1122 | |
| 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); | |
| 1146 | 1136 | } |
| 1147 | 1137 | |
| 1148 | 1138 | export function sortProjectsForSidebar< |
apps/web/src/components/Sidebar.tsx+6-4| ... | ... | @@ -21,7 +21,10 @@ import { |
| 21 | 21 | effectiveSnoozed, |
| 22 | 22 | threadWokeAt, |
| 23 | 23 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 24 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; | |
| 24 | import { | |
| 25 | resolveSettledThreadTimestamp, | |
| 26 | sortSettledThreads, | |
| 27 | } from "@t3tools/client-runtime/state/thread-sort"; | |
| 25 | 28 | import { |
| 26 | 29 | threadSearchMatchKey, |
| 27 | 30 | type EnvironmentThreadSearchMatch, |
| ... | ... | @@ -181,7 +184,6 @@ import { |
| 181 | 184 | sidebarMarkerId, |
| 182 | 185 | sortLogicalProjectsForSidebar, |
| 183 | 186 | sortPinnedThreadsForSidebar, |
| 184 | sortSettledThreadsForSidebar, | |
| 185 | 187 | sortThreadsForSidebar, |
| 186 | 188 | useRetainedValue, |
| 187 | 189 | useSidebarRowSubscriptionLease, |
| ... | ... | @@ -2626,7 +2628,7 @@ export default function Sidebar() { |
| 2626 | 2628 | firstValidTimestampMs(left.snoozedUntil ?? null) - |
| 2627 | 2629 | firstValidTimestampMs(right.snoozedUntil ?? null), |
| 2628 | 2630 | ), |
| 2629 | settledThreads: sortSettledThreadsForSidebar(settled), | |
| 2631 | settledThreads: sortSettledThreads(settled), | |
| 2630 | 2632 | snoozeNow: preciseNow, |
| 2631 | 2633 | }; |
| 2632 | 2634 | }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); |
| ... | ... | @@ -3452,7 +3454,7 @@ export default function Sidebar() { |
| 3452 | 3454 | if (dragState === null || thread === undefined) return []; |
| 3453 | 3455 | const key = (candidate: EnvironmentThreadShell) => |
| 3454 | 3456 | scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); |
| 3455 | return sortSettledThreadsForSidebar([ | |
| 3457 | return sortSettledThreads([ | |
| 3456 | 3458 | ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), |
| 3457 | 3459 | applySidebarThreadDrop(thread, "settled", dragState.occurredAt), |
| 3458 | 3460 | ]).map(key); |
apps/web/src/lib/threadSort.ts-2| ... | ... | @@ -1,9 +1,7 @@ |
| 1 | 1 | export { |
| 2 | 2 | getLatestThreadForProject, |
| 3 | 3 | getThreadSortTimestamp, |
| 4 | resolveSettledThreadTimestamp, | |
| 5 | 4 | sortThreads, |
| 6 | 5 | toSortableTimestamp, |
| 7 | type SettledThreadTimestampInput, | |
| 8 | 6 | type ThreadSortInput, |
| 9 | 7 | } from "@t3tools/client-runtime/state/thread-sort"; |
packages/client-runtime/src/state/threadSort.test.ts+106-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| ... | ... | @@ -10,7 +10,9 @@ import { |
| 10 | 10 | resolveSettledThreadTimestamp, |
| 11 | 11 | sortActiveThreadsByOrderKey, |
| 12 | 12 | sortPinnedThreadsByOrderKey, |
| 13 | sortSettledThreads, | |
| 13 | 14 | sortThreads, |
| 15 | type SettledThreadTimestampInput, | |
| 14 | 16 | type ThreadSortInput, |
| 15 | 17 | } from "./threadSort.ts"; |
| 16 | 18 | |
| ... | ... | @@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => { |
| 59 | 61 | }); |
| 60 | 62 | }); |
| 61 | 63 | |
| 64 | describe("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 | ||
| 62 | 167 | describe("sortThreads", () => { |
| 63 | 168 | it.each(["created_at", "updated_at"] as const)( |
| 64 | 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 | 47 | return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; |
| 48 | 48 | } |
| 49 | 49 | |
| 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. */ | |
| 53 | export 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 | ||
| 50 | 68 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { |
| 51 | 69 | for (const value of values) { |
| 52 | 70 | const timestamp = toSortableTimestamp(value ?? undefined); |