Skip to content
Source
Sign inSign up

threadSort.ts

349 lines12.4 KB

1import type { ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { EnvironmentThreadShell } from "./models.ts";
4​
5export interface ThreadSortInput {
6 readonly createdAt: string;
7 readonly updatedAt: string;
8 readonly latestUserMessageAt?: string | null;
9 readonly latestUserAuthoredMessageAt?: string | null;
10 readonly messages?: ReadonlyArray<{
11 readonly createdAt: string;
12 readonly role: string;
13 }>;
15​
16export function toSortableTimestamp(iso: string | undefined): number | null {
17 if (!iso) return null;
18 const ms = Date.parse(iso);
19 return Number.isFinite(ms) ? ms : null;
21​
22export type SettledThreadTimestampInput = Pick<
23 EnvironmentThreadShell,
24 "settledAt" | "latestUserMessageAt" | "latestRun" | "updatedAt"
25>;
26​
27export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null {
28 if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) {
29 return thread.settledAt;
30 }
31 return resolveThreadActivityTimestamp(thread);
33​
34export function resolveThreadActivityTimestamp(
35 thread: Pick<EnvironmentThreadShell, "latestUserMessageAt" | "latestRun" | "updatedAt">,
36): string | null {
37 let latest: string | null = null;
38 let latestMs = Number.NEGATIVE_INFINITY;
39 for (const candidate of [
40 thread.latestUserMessageAt,
41 thread.latestRun?.requestedAt,
42 thread.latestRun?.startedAt,
43 thread.latestRun?.completedAt,
44 ]) {
45 const parsed = toSortableTimestamp(candidate ?? undefined);
46 if (candidate != null && parsed !== null && parsed > latestMs) {
47 latest = candidate;
48 latestMs = parsed;
49 }
50 }
51 if (latest !== null) return latest;
52 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
54​
55export function sortSettledThreads<T extends SettledThreadTimestampInput & { readonly id: string }>(
56 threads: readonly T[],
57): T[] {
58 return threads
59 .map((thread) => {
60 const timestamp = resolveSettledThreadTimestamp(thread);
61 return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) };
62 })
63 .sort(
64 (left, right) =>
65 right.timestampMs - left.timestampMs ||
66 // Plain order, as the server pages ties (ids mix upper and lower case).
67 (left.thread.id < right.thread.id ? -1 : left.thread.id > right.thread.id ? 1 : 0),
68 )
69 .map(({ thread }) => thread);
71​
72function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
73 for (const value of values) {
74 const timestamp = toSortableTimestamp(value ?? undefined);
75 if (timestamp !== null) {
76 return timestamp;
77 }
78 }
79​
80 return null;
82​
83function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
84 // Agents also post in the user role; only what the user wrote counts. A thread
85 // the user never wrote in counts from its creation; older servers omit the field.
86 if (thread.latestUserAuthoredMessageAt !== undefined) {
87 return (
88 getFirstSortableTimestamp(
89 thread.latestUserAuthoredMessageAt,
90 thread.createdAt,
91 thread.updatedAt,
92 ) ?? Number.NEGATIVE_INFINITY
93 );
94 }
95​
96 if (thread.latestUserMessageAt) {
97 const latestUserMessageTimestamp = toSortableTimestamp(thread.latestUserMessageAt);
98 if (latestUserMessageTimestamp !== null) {
99 return latestUserMessageTimestamp;
100 }
101 }
102​
103 let latestUserMessageTimestamp: number | null = null;
104​
105 for (const message of thread.messages ?? []) {
106 if (message.role !== "user") continue;
107 const messageTimestamp = toSortableTimestamp(message.createdAt);
108 if (messageTimestamp === null) continue;
109 latestUserMessageTimestamp =
110 latestUserMessageTimestamp === null
111 ? messageTimestamp
112 : Math.max(latestUserMessageTimestamp, messageTimestamp);
113 }
114​
115 if (latestUserMessageTimestamp !== null) {
116 return latestUserMessageTimestamp;
117 }
118​
119 return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
121​
122export function getThreadSortTimestamp(
123 thread: ThreadSortInput,
124 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
125): number {
126 if (sortOrder === "created_at") {
127 return (
128 getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
129 );
130 }
131 return getLatestUserMessageTimestamp(thread);
133​
134function activeThreadAnchorTimestampMs(thread: {
135 readonly createdAt: string;
136 readonly unsettledAt?: string | null | undefined;
137}): number {
138 return Math.max(
139 toSortableTimestamp(thread.createdAt) ?? 0,
140 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
141 );
143​
144export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
145 threads: readonly T[],
146 sortOrder: SidebarThreadSortOrder,
147): T[] {
148 if (threads.length < 2) return [...threads];
149 return threads
150 .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) }))
151 .sort(
152 (left, right) =>
153 right.timestamp - left.timestamp ||
154 (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0),
155 )
156 .map(({ thread }) => thread);
158​
159export function getLatestThreadForProject<
160 T extends {
161 readonly id: string;
162 readonly projectId: ProjectId;
163 readonly archivedAt: string | null;
164 } & ThreadSortInput,
165>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
166 let latest: T | null = null;
167 let latestTimestamp = Number.NEGATIVE_INFINITY;
168 for (const thread of threads) {
169 if (thread.projectId !== projectId || thread.archivedAt !== null) continue;
170 const timestamp = getThreadSortTimestamp(thread, sortOrder);
171 if (
172 latest === null ||
173 timestamp > latestTimestamp ||
174 (timestamp === latestTimestamp && thread.id > latest.id)
175 ) {
176 latest = thread;
177 latestTimestamp = timestamp;
178 }
179 }
180 return latest;
182​
183const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz";
184​
185function isValidPinOrderKey(key: string): boolean {
186 if (key.length === 0) return false;
187 for (const char of key) {
188 if (!PIN_ORDER_DIGITS.includes(char)) return false;
189 }
190 // A trailing minimum digit would leave no room to sort a key immediately
191 // before this one; generators never produce it, so treat it as corrupt.
192 return key.at(-1) !== PIN_ORDER_DIGITS[0];
194​
195/** Midpoint of two digit strings interpreted as fractions in (0, 1).
196 "" stands for the open bound on either side. Requires a < b. */
197function pinOrderMidpoint(a: string, b: string): string {
198 if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order");
199 if (b !== "") {
200 let n = 0;
201 while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1;
202 if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n));
203 }
204 const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0));
205 const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0));
206 if (digitB - digitA > 1) {
207 return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2));
208 }
209 if (b.length > 1) return b.charAt(0);
210 return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), "");
212​
213/** Key that sorts strictly between two neighbors; null bounds mean "top of
214 the pinned block" / "bottom of the keyed run". Returns null instead of
215 throwing when existing keys are corrupt or out of order — callers fall
216 back to rewriting the section. */
217export function pinOrderKeyBetween(before: string | null, after: string | null): string | null {
218 const a = before ?? "";
219 const b = after ?? "";
220 if (a !== "" && !isValidPinOrderKey(a)) return null;
221 if (b !== "" && !isValidPinOrderKey(b)) return null;
222 if (b !== "" && a >= b) return null;
223 return pinOrderMidpoint(a, b);
225​
226export function generateSpreadPinOrderKeys(count: number): string[] {
227 let width = 2;
228 let space = PIN_ORDER_DIGITS.length ** width;
229 while (space <= (count + 1) * 2) {
230 width += 1;
231 space *= PIN_ORDER_DIGITS.length;
232 }
233 const step = space / (count + 1);
234 const keys: string[] = [];
235 for (let i = 0; i < count; i += 1) {
236 let value = Math.round(step * (i + 1));
237 if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
238 let key = "";
239 for (let digit = 0; digit < width; digit += 1) {
240 key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key;
241 value = Math.floor(value / PIN_ORDER_DIGITS.length);
242 }
243 keys.push(key);
244 }
245 return keys;
247​
248export function planPinnedReorder(input: {
249 readonly orderedIds: readonly string[];
250 /** Include retained keys from hidden rows; only orderedIds receive writes. */
251 readonly keysById: ReadonlyMap<string, string | null | undefined>;
252 readonly movedId: string;
253}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
254 const { orderedIds, keysById, movedId } = input;
255 const visibleIds = new Set(orderedIds);
256 const reservedKeys = new Set(
257 [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])),
258 );
259 const movedIndex = orderedIds.indexOf(movedId);
260 if (movedIndex === -1) return [];
261 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
262 const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null;
263 const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null;
264 const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null;
265 const beforeUsable = beforeId === null || beforeKey != null;
266 const afterUsable = afterId === null || afterKey != null;
267 if (beforeUsable && afterUsable) {
268 let key = pinOrderKeyBetween(beforeKey, afterKey);
269 while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
270 if (key !== null) return [{ id: movedId, orderKey: key }];
271 }
272 const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
273 .filter((key) => !reservedKeys.has(key))
274 .slice(0, orderedIds.length);
275 return orderedIds.flatMap((id, index) => {
276 const key = keys[index]!;
277 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
278 });
280​
281export function sortPinnedThreadsByOrderKey<
282 T extends {
283 readonly id: string;
284 readonly createdAt: string;
285 readonly pinOrderKey?: string | null | undefined;
286 /** Thread ids are only unique within an environment, and the pinned
287 block merges environments — the tiebreak needs both parts or two
288 clients could render equal-key threads in stream-arrival order. */
289 readonly environmentId?: string | undefined;
290 },
291>(threads: readonly T[]): T[] {
292 if (threads.length < 2) return [...threads];
293 const keyed: T[] = [];
294 const keyless: T[] = [];
295 for (const thread of threads) {
296 (thread.pinOrderKey != null ? keyed : keyless).push(thread);
297 }
298 const identityTiebreak = (left: T, right: T) =>
299 left.id.localeCompare(right.id) ||
300 (left.environmentId ?? "").localeCompare(right.environmentId ?? "");
301 keyed.sort((left, right) => {
302 const leftKey = left.pinOrderKey!;
303 const rightKey = right.pinOrderKey!;
304 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);
305 });
306 const timestamps = new Map(
307 keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]),
308 );
309 keyless.sort(
310 (left, right) =>
311 timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right),
312 );
313 return [...keyed, ...keyless];
315​
316export function sortActiveThreadsByOrderKey<
317 T extends {
318 readonly id: string;
319 readonly createdAt: string;
320 readonly unsettledAt?: string | null | undefined;
321 readonly activeOrderKey?: string | null | undefined;
322 readonly environmentId?: string | undefined;
323 },
324>(threads: readonly T[]): T[] {
325 if (threads.length < 2) return [...threads];
326 const timestamps = new Map<T, number>();
327 for (const thread of threads) {
328 if (thread.activeOrderKey == null) {
329 timestamps.set(thread, activeThreadAnchorTimestampMs(thread));
330 }
331 }
332 return [...threads].sort((left, right) => {
333 const leftKey = left.activeOrderKey;
334 const rightKey = right.activeOrderKey;
335 if (leftKey == null && rightKey != null) return -1;
336 if (leftKey != null && rightKey == null) return 1;
337 let order = 0;
338 if (leftKey != null && rightKey != null) {
339 order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
340 } else {
341 order = timestamps.get(right)! - timestamps.get(left)!;
342 }
343 return (
344 order ||
345 left.id.localeCompare(right.id) ||
346 (left.environmentId ?? "").localeCompare(right.environmentId ?? "")
347 );
348 });