1import type { ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { EnvironmentThreadShell } from "./models.ts";
4import * as Arr from "effect/Array";
5import * as Order from "effect/Order";
6
7export interface ThreadSortInput {
8 readonly createdAt: string;
9 readonly updatedAt: string;
10 readonly latestUserMessageAt?: string | null;
11 readonly messages?: ReadonlyArray<{
12 readonly createdAt: string;
13 readonly role: string;
14 }>;
15}
16
17export function toSortableTimestamp(iso: string | undefined): number | null {
18 if (!iso) return null;
19 const ms = Date.parse(iso);
20 return Number.isFinite(ms) ? ms : null;
21}
22
23export type SettledThreadTimestampInput = Pick<
24 EnvironmentThreadShell,
25 "settledAt" | "latestUserMessageAt" | "latestRun" | "updatedAt"
26>;
27
28/** The timestamp a settled row sorts and labels by on every client: settledAt
29 when stamped, otherwise the latest message or turn stamp, then updatedAt. */
30export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null {
31 if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) {
32 return thread.settledAt;
33 }
34
35 let latest: string | null = null;
36 let latestMs = Number.NEGATIVE_INFINITY;
37 for (const candidate of [
38 thread.latestUserMessageAt,
39 thread.latestRun?.requestedAt,
40 thread.latestRun?.startedAt,
41 thread.latestRun?.completedAt,
42 ]) {
43 const parsed = toSortableTimestamp(candidate ?? undefined);
44 if (candidate != null && parsed !== null && parsed > latestMs) {
45 latest = candidate;
46 latestMs = parsed;
47 }
48 }
49 if (latest !== null) return latest;
50 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
51}
52
53/** Settled rows are history, so they order by when the work ENDED, newest
54 first, with an id tiebreak. Each key resolves once per sort, not once
55 per comparison. Shared by web and mobile so both render the same order. */
56export function sortSettledThreads<T extends SettledThreadTimestampInput & { readonly id: string }>(
57 threads: readonly T[],
58): T[] {
59 return threads
60 .map((thread) => {
61 const timestamp = resolveSettledThreadTimestamp(thread);
62 return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) };
63 })
64 .sort(
65 (left, right) =>
66 right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id),
67 )
68 .map(({ thread }) => thread);
69}
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;
80}
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;
107}
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);
119}
120
121/**
122 * Sort anchor for the active thread list: creation time, re-anchored to
123 * unsettledAt when the thread last re-entered the active list (an explicit
124 * un-settle, or a settled thread waking on activity). The list stays static
125 * between lifecycle transitions, but an un-settled thread surfaces at the
126 * top instead of sinking back to its creation-order slot. Shared by web and
127 * mobile so both render the same order. Malformed timestamps sink to 0.
128 */
129export function activeThreadAnchorTimestampMs(thread: {
130 readonly createdAt: string;
131 readonly unsettledAt?: string | null | undefined;
132}): number {
133 return Math.max(
134 toSortableTimestamp(thread.createdAt) ?? 0,
135 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
136 );
137}
138
139export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
140 threads: readonly T[],
141 sortOrder: SidebarThreadSortOrder,
142): T[] {
143 if (threads.length < 2) return [...threads];
144 return threads
145 .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) }))
146 .sort(
147 (left, right) =>
148 right.timestamp - left.timestamp ||
149 (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0),
150 )
151 .map(({ thread }) => thread);
152}
153
154export function getLatestThreadForProject<
155 T extends {
156 readonly id: string;
157 readonly projectId: ProjectId;
158 readonly archivedAt: string | null;
159 } & ThreadSortInput,
160>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
161 let latest: T | null = null;
162 let latestTimestamp = Number.NEGATIVE_INFINITY;
163 for (const thread of threads) {
164 if (thread.projectId !== projectId || thread.archivedAt !== null) continue;
165 const timestamp = getThreadSortTimestamp(thread, sortOrder);
166 if (
167 latest === null ||
168 timestamp > latestTimestamp ||
169 (timestamp === latestTimestamp && thread.id > latest.id)
170 ) {
171 latest = thread;
172 latestTimestamp = timestamp;
173 }
174 }
175 return latest;
176}
177
178// ── Pinned reorder: fractional index keys ──────────────────────────────
179// Pinned threads carry an optional pinOrderKey (a base-26 string). The
180// pinned block sorts keyed threads by plain string comparison, so a drag
181// (web) or Move up/down (mobile) writes ONE key to ONE thread on that
182// thread's own server — neighbors, possibly living on other servers, are
183// never touched, and every client connected to the same servers converges
184// on the same order.
185const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz";
186
187function isValidPinOrderKey(key: string): boolean {
188 if (key.length === 0) return false;
189 for (const char of key) {
190 if (!PIN_ORDER_DIGITS.includes(char)) return false;
191 }
192 // A trailing minimum digit would leave no room to sort a key immediately
193 // before this one; generators never produce it, so treat it as corrupt.
194 return key.at(-1) !== PIN_ORDER_DIGITS[0];
195}
196
197/** Midpoint of two digit strings interpreted as fractions in (0, 1).
198 "" stands for the open bound on either side. Requires a < b. */
199function pinOrderMidpoint(a: string, b: string): string {
200 if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order");
201 if (b !== "") {
202 // Recurse past the longest common prefix ("a" pads the shorter side).
203 let n = 0;
204 while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1;
205 if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n));
206 }
207 const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0));
208 const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0));
209 if (digitB - digitA > 1) {
210 return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2));
211 }
212 // Consecutive leading digits: either b has spare digits to shorten into,
213 // or we extend a (never producing a trailing minimum digit — the base
214 // case midpoint("", "") is the middle of the alphabet).
215 if (b.length > 1) return b.charAt(0);
216 return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), "");
217}
218
219/** Key that sorts strictly between two neighbors; null bounds mean "top of
220 the pinned block" / "bottom of the keyed run". Returns null instead of
221 throwing when existing keys are corrupt or out of order — callers fall
222 back to rewriting the section. */
223export function pinOrderKeyBetween(before: string | null, after: string | null): string | null {
224 const a = before ?? "";
225 const b = after ?? "";
226 if (a !== "" && !isValidPinOrderKey(a)) return null;
227 if (b !== "" && !isValidPinOrderKey(b)) return null;
228 if (b !== "" && a >= b) return null;
229 return pinOrderMidpoint(a, b);
230}
231
232/** Evenly spaced keys for materializing an order. Wider keys keep a large
233 active list from exhausting the space between two-digit keys. */
234export function generateSpreadPinOrderKeys(count: number): string[] {
235 let width = 2;
236 let space = PIN_ORDER_DIGITS.length ** width;
237 while (space <= (count + 1) * 2) {
238 width += 1;
239 space *= PIN_ORDER_DIGITS.length;
240 }
241 const step = space / (count + 1);
242 const keys: string[] = [];
243 for (let i = 0; i < count; i += 1) {
244 let value = Math.round(step * (i + 1));
245 // Skip values whose low digit is the minimum (a trailing "a" key).
246 if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
247 let key = "";
248 for (let digit = 0; digit < width; digit += 1) {
249 key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key;
250 value = Math.floor(value / PIN_ORDER_DIGITS.length);
251 }
252 keys.push(key);
253 }
254 return keys;
255}
256
257/**
258 * Assignments needed to realize a new pinned order. When the moved thread
259 * sits between two keyed (or absent) neighbors, this is a single write to
260 * the moved thread. When a neighbor is keyless (threads pinned before
261 * reordering shipped), the whole section gets fresh spread keys — a
262 * one-time materialization; every move after that is single-write. Active
263 * reordering uses the same planner with activeOrderKey values.
264 */
265export function planPinnedReorder(input: {
266 /** Thread ids in the desired visual order (after the move). */
267 readonly orderedIds: readonly string[];
268 /** Include retained keys from hidden rows; only orderedIds receive writes. */
269 readonly keysById: ReadonlyMap<string, string | null | undefined>;
270 readonly movedId: string;
271}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
272 const { orderedIds, keysById, movedId } = input;
273 const visibleIds = new Set(orderedIds);
274 const reservedKeys = new Set(
275 [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])),
276 );
277 const movedIndex = orderedIds.indexOf(movedId);
278 if (movedIndex === -1) return [];
279 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
280 const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null;
281 const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null;
282 const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null;
283 const beforeUsable = beforeId === null || beforeKey != null;
284 const afterUsable = afterId === null || afterKey != null;
285 if (beforeUsable && afterUsable) {
286 let key = pinOrderKeyBetween(beforeKey, afterKey);
287 while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
288 if (key !== null) return [{ id: movedId, orderKey: key }];
289 }
290 // Keyless neighbor (or corrupt keys): rewrite the section in the new order.
291 const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
292 .filter((key) => !reservedKeys.has(key))
293 .slice(0, orderedIds.length);
294 return orderedIds.flatMap((id, index) => {
295 const key = keys[index]!;
296 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
297 });
298}
299
300/**
301 * Pinned block order: user-arranged keys first (string comparison, id
302 * tiebreak), then keyless threads newest-created first — so threads on
303 * servers that predate reordering keep the static creation order at the
304 * bottom of the block instead of breaking the section.
305 */
306export function sortPinnedThreadsByOrderKey<
307 T extends {
308 readonly id: string;
309 readonly createdAt: string;
310 readonly pinOrderKey?: string | null | undefined;
311 /** Thread ids are only unique within an environment, and the pinned
312 block merges environments — the tiebreak needs both parts or two
313 clients could render equal-key threads in stream-arrival order. */
314 readonly environmentId?: string | undefined;
315 },
316>(threads: readonly T[]): T[] {
317 if (threads.length < 2) return [...threads];
318 const keyed: T[] = [];
319 const keyless: T[] = [];
320 for (const thread of threads) {
321 (thread.pinOrderKey != null ? keyed : keyless).push(thread);
322 }
323 const identityTiebreak = (left: T, right: T) =>
324 left.id.localeCompare(right.id) ||
325 (left.environmentId ?? "").localeCompare(right.environmentId ?? "");
326 keyed.sort((left, right) => {
327 const leftKey = left.pinOrderKey!;
328 const rightKey = right.pinOrderKey!;
329 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);
330 });
331 const timestamps = new Map(
332 keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]),
333 );
334 keyless.sort(
335 (left, right) =>
336 timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right),
337 );
338 return [...keyed, ...keyless];
339}
340
341/** New and reopened threads lead the active list. Arranged threads follow
342 their saved keys; activity leaves both groups in place. */
343export function sortActiveThreadsByOrderKey<
344 T extends {
345 readonly id: string;
346 readonly createdAt: string;
347 readonly unsettledAt?: string | null | undefined;
348 readonly activeOrderKey?: string | null | undefined;
349 readonly environmentId?: string | undefined;
350 },
351>(threads: readonly T[]): T[] {
352 if (threads.length < 2) return [...threads];
353 const timestamps = new Map<T, number>();
354 for (const thread of threads) {
355 if (thread.activeOrderKey == null) {
356 timestamps.set(thread, activeThreadAnchorTimestampMs(thread));
357 }
358 }
359 return [...threads].sort((left, right) => {
360 const leftKey = left.activeOrderKey;
361 const rightKey = right.activeOrderKey;
362 if (leftKey == null && rightKey != null) return -1;
363 if (leftKey != null && rightKey == null) return 1;
364 let order = 0;
365 if (leftKey != null && rightKey != null) {
366 order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
367 } else {
368 order = timestamps.get(right)! - timestamps.get(left)!;
369 }
370 return (
371 order ||
372 left.id.localeCompare(right.id) ||
373 (left.environmentId ?? "").localeCompare(right.environmentId ?? "")
374 );
375 });
376}
377
378/**
379 * planPinnedReorder specialized for mobile's Move up / Move down menu
380 * actions: swap the moved thread with its displayed neighbor. Null when the
381 * move falls off either end of the list. Same single-write-per-move
382 * semantics as a web drag.
383 */
384export function planPinnedMove(input: {
385 /** Reorder-capable pinned thread ids in displayed order. */
386 readonly orderedIds: readonly string[];
387 readonly keysById: ReadonlyMap<string, string | null | undefined>;
388 readonly movedId: string;
389 readonly direction: "up" | "down";
390}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null {
391 const { orderedIds, keysById, movedId, direction } = input;
392 const from = orderedIds.indexOf(movedId);
393 if (from === -1) return null;
394 const to = direction === "up" ? from - 1 : from + 1;
395 if (to < 0 || to >= orderedIds.length) return null;
396 const newOrder = [...orderedIds];
397 newOrder.splice(from, 1);
398 newOrder.splice(to, 0, movedId);
399 return planPinnedReorder({ orderedIds: newOrder, keysById, movedId });
400}