import type { ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { EnvironmentThreadShell } from "./models.ts"; export interface ThreadSortInput { readonly createdAt: string; readonly updatedAt: string; readonly latestUserMessageAt?: string | null; readonly latestUserAuthoredMessageAt?: string | null; readonly messages?: ReadonlyArray<{ readonly createdAt: string; readonly role: string; }>; } export function toSortableTimestamp(iso: string | undefined): number | null { if (!iso) return null; const ms = Date.parse(iso); return Number.isFinite(ms) ? ms : null; } export type SettledThreadTimestampInput = Pick< EnvironmentThreadShell, "settledAt" | "latestUserMessageAt" | "latestRun" | "updatedAt" >; export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null { if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) { return thread.settledAt; } return resolveThreadActivityTimestamp(thread); } export function resolveThreadActivityTimestamp( thread: Pick, ): string | null { let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const candidate of [ thread.latestUserMessageAt, thread.latestRun?.requestedAt, thread.latestRun?.startedAt, thread.latestRun?.completedAt, ]) { const parsed = toSortableTimestamp(candidate ?? undefined); if (candidate != null && parsed !== null && parsed > latestMs) { latest = candidate; latestMs = parsed; } } if (latest !== null) return latest; return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; } export function sortSettledThreads( threads: readonly T[], ): T[] { return threads .map((thread) => { const timestamp = resolveSettledThreadTimestamp(thread); return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) }; }) .sort( (left, right) => right.timestampMs - left.timestampMs || // Plain order, as the server pages ties (ids mix upper and lower case). (left.thread.id < right.thread.id ? -1 : left.thread.id > right.thread.id ? 1 : 0), ) .map(({ thread }) => thread); } function getFirstSortableTimestamp(...values: Array): number | null { for (const value of values) { const timestamp = toSortableTimestamp(value ?? undefined); if (timestamp !== null) { return timestamp; } } return null; } function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { // Agents also post in the user role; only what the user wrote counts. A thread // the user never wrote in counts from its creation; older servers omit the field. if (thread.latestUserAuthoredMessageAt !== undefined) { return ( getFirstSortableTimestamp( thread.latestUserAuthoredMessageAt, thread.createdAt, thread.updatedAt, ) ?? Number.NEGATIVE_INFINITY ); } if (thread.latestUserMessageAt) { const latestUserMessageTimestamp = toSortableTimestamp(thread.latestUserMessageAt); if (latestUserMessageTimestamp !== null) { return latestUserMessageTimestamp; } } let latestUserMessageTimestamp: number | null = null; for (const message of thread.messages ?? []) { if (message.role !== "user") continue; const messageTimestamp = toSortableTimestamp(message.createdAt); if (messageTimestamp === null) continue; latestUserMessageTimestamp = latestUserMessageTimestamp === null ? messageTimestamp : Math.max(latestUserMessageTimestamp, messageTimestamp); } if (latestUserMessageTimestamp !== null) { return latestUserMessageTimestamp; } return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; } export function getThreadSortTimestamp( thread: ThreadSortInput, sortOrder: SidebarThreadSortOrder | Exclude, ): number { if (sortOrder === "created_at") { return ( getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY ); } return getLatestUserMessageTimestamp(thread); } function activeThreadAnchorTimestampMs(thread: { readonly createdAt: string; readonly unsettledAt?: string | null | undefined; }): number { return Math.max( toSortableTimestamp(thread.createdAt) ?? 0, toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, ); } export function sortThreads( threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { if (threads.length < 2) return [...threads]; return threads .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) })) .sort( (left, right) => right.timestamp - left.timestamp || (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0), ) .map(({ thread }) => thread); } export function getLatestThreadForProject< T extends { readonly id: string; readonly projectId: ProjectId; readonly archivedAt: string | null; } & ThreadSortInput, >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { let latest: T | null = null; let latestTimestamp = Number.NEGATIVE_INFINITY; for (const thread of threads) { if (thread.projectId !== projectId || thread.archivedAt !== null) continue; const timestamp = getThreadSortTimestamp(thread, sortOrder); if ( latest === null || timestamp > latestTimestamp || (timestamp === latestTimestamp && thread.id > latest.id) ) { latest = thread; latestTimestamp = timestamp; } } return latest; } const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; function isValidPinOrderKey(key: string): boolean { if (key.length === 0) return false; for (const char of key) { if (!PIN_ORDER_DIGITS.includes(char)) return false; } // A trailing minimum digit would leave no room to sort a key immediately // before this one; generators never produce it, so treat it as corrupt. return key.at(-1) !== PIN_ORDER_DIGITS[0]; } /** Midpoint of two digit strings interpreted as fractions in (0, 1). "" stands for the open bound on either side. Requires a < b. */ function pinOrderMidpoint(a: string, b: string): string { if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); if (b !== "") { let n = 0; while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); } const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); if (digitB - digitA > 1) { return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); } if (b.length > 1) return b.charAt(0); return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); } /** Key that sorts strictly between two neighbors; null bounds mean "top of the pinned block" / "bottom of the keyed run". Returns null instead of throwing when existing keys are corrupt or out of order — callers fall back to rewriting the section. */ export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { const a = before ?? ""; const b = after ?? ""; if (a !== "" && !isValidPinOrderKey(a)) return null; if (b !== "" && !isValidPinOrderKey(b)) return null; if (b !== "" && a >= b) return null; return pinOrderMidpoint(a, b); } export function generateSpreadPinOrderKeys(count: number): string[] { let width = 2; let space = PIN_ORDER_DIGITS.length ** width; while (space <= (count + 1) * 2) { width += 1; space *= PIN_ORDER_DIGITS.length; } const step = space / (count + 1); const keys: string[] = []; for (let i = 0; i < count; i += 1) { let value = Math.round(step * (i + 1)); if (value % PIN_ORDER_DIGITS.length === 0) value += 1; let key = ""; for (let digit = 0; digit < width; digit += 1) { key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key; value = Math.floor(value / PIN_ORDER_DIGITS.length); } keys.push(key); } return keys; } export function planPinnedReorder(input: { readonly orderedIds: readonly string[]; /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; const visibleIds = new Set(orderedIds); const reservedKeys = new Set( [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), ); const movedIndex = orderedIds.indexOf(movedId); if (movedIndex === -1) return []; const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; if (beforeUsable && afterUsable) { let key = pinOrderKeyBetween(beforeKey, afterKey); while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); if (key !== null) return [{ id: movedId, orderKey: key }]; } const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) .filter((key) => !reservedKeys.has(key)) .slice(0, orderedIds.length); return orderedIds.flatMap((id, index) => { const key = keys[index]!; return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; }); } export function sortPinnedThreadsByOrderKey< T extends { readonly id: string; readonly createdAt: string; readonly pinOrderKey?: string | null | undefined; /** Thread ids are only unique within an environment, and the pinned block merges environments — the tiebreak needs both parts or two clients could render equal-key threads in stream-arrival order. */ readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { if (threads.length < 2) return [...threads]; const keyed: T[] = []; const keyless: T[] = []; for (const thread of threads) { (thread.pinOrderKey != null ? keyed : keyless).push(thread); } const identityTiebreak = (left: T, right: T) => left.id.localeCompare(right.id) || (left.environmentId ?? "").localeCompare(right.environmentId ?? ""); keyed.sort((left, right) => { const leftKey = left.pinOrderKey!; const rightKey = right.pinOrderKey!; return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); }); const timestamps = new Map( keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]), ); keyless.sort( (left, right) => timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right), ); return [...keyed, ...keyless]; } export function sortActiveThreadsByOrderKey< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; readonly activeOrderKey?: string | null | undefined; readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { if (threads.length < 2) return [...threads]; const timestamps = new Map(); for (const thread of threads) { if (thread.activeOrderKey == null) { timestamps.set(thread, activeThreadAnchorTimestampMs(thread)); } } return [...threads].sort((left, right) => { const leftKey = left.activeOrderKey; const rightKey = right.activeOrderKey; if (leftKey == null && rightKey != null) return -1; if (leftKey != null && rightKey == null) return 1; let order = 0; if (leftKey != null && rightKey != null) { order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; } else { order = timestamps.get(right)! - timestamps.get(left)!; } return ( order || left.id.localeCompare(right.id) || (left.environmentId ?? "").localeCompare(right.environmentId ?? "") ); }); }