| 1 | import type { ProjectId } from "@t3tools/contracts"; |
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 3 | import type { EnvironmentThreadShell } from "./models.ts"; |
| 4 | import * as Arr from "effect/Array"; |
| 5 | import * as Order from "effect/Order"; |
| 6 | |
| 7 | export 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 | |
| 17 | export 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 | |
| 23 | export 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. */ |
| 30 | export 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. */ |
| 56 | export 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 | |
| 71 | function 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 | |
| 82 | function 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 | |
| 109 | export 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 | */ |
| 129 | export 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 | |
| 139 | export 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 | |
| 154 | export 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. |
| 185 | const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; |
| 186 | |
| 187 | function 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. */ |
| 199 | function 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. */ |
| 223 | export 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. */ |
| 234 | export 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 | */ |
| 265 | export 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 | */ |
| 306 | export 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. */ |
| 343 | export 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 | */ |
| 384 | export 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 | } |