Skip to content
Source
Sign inSign up

threadSort.ts

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