| 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,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"; |
| 18 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 19 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 19 | 20 | ||
| ... | @@ -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"; |
| 53 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 52 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 53 | import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 54 | import { | 54 | import { |
| 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 | }); |
| 1701 | 1701 | ||
| ... | @@ -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 | }); |
| 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 | describe("resolveWorkingStartedAt", () => { | 1823 | describe("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 | }); |
| 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 | 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"; |
| 17 | import { | 17 | import { |
| 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"; |
| 25 | import type { SidebarThreadSummary, Thread } from "../types"; | 23 | import type { SidebarThreadSummary, Thread } from "../types"; |
| ... | @@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState( | ... | @@ -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 | /** The timestamp a working thread's elapsed label counts from: the running | 938 | /** 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's | 939 | turn's start (request time until adoption), falling back to the session's |
| 956 | last transition when the turn projection lags behind. Malformed | 940 | 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 | } |
| 1138 | 1122 | ||
| 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 | } |
| 1147 | 1137 | ||
| 1148 | export function sortProjectsForSidebar< | 1138 | export 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"; |
| 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 | import { | 28 | import { |
| 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 @@ |
| 1 | export { | 1 | export { |
| 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 @@ |
| 1 | import { ProjectId } from "@t3tools/contracts"; | 1 | import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -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"; |
| 16 | 18 | ||
| ... | @@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => { | ... | @@ -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 | describe("sortThreads", () => { | 167 | describe("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 | } |
| 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 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { | 68 | function 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); |