import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";import * as Arr from "effect/Array";import * as Order from "effect/Order"; export interface ThreadSortInput { readonly createdAt: string; readonly updatedAt: string; readonly latestUserMessageAt?: 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< OrchestrationThreadShell, "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt">; /** The timestamp a settled row sorts and labels by on every client: settledAt when stamped, otherwise the latest message or turn stamp, then updatedAt. */export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null { if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) { return thread.settledAt; } let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const candidate of [ thread.latestUserMessageAt, thread.latestTurn?.requestedAt, thread.latestTurn?.startedAt, thread.latestTurn?.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;} function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { for (const value of values) { const timestamp = toSortableTimestamp(value ?? undefined); if (timestamp !== null) { return timestamp; } } return null;} function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { 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<SidebarProjectSortOrder, "manual">,): number { if (sortOrder === "created_at") { return ( getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY ); } return getLatestUserMessageTimestamp(thread);} /** * Sort anchor for the active thread list: creation time, re-anchored to * unsettledAt when the thread last re-entered the active list (an explicit * un-settle, or a settled thread waking on activity). The list stays static * between lifecycle transitions, but an un-settled thread surfaces at the * top instead of sinking back to its creation-order slot. Shared by web and * mobile so both render the same order. Malformed timestamps sink to 0. */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<T extends { readonly id: string } & ThreadSortInput>( threads: readonly T[], sortOrder: SidebarThreadSortOrder,): T[] { return Arr.sort( threads, Order.mapInput( Order.Struct({ timestamp: Order.flip(Order.Number), id: Order.flip(Order.String), }), (thread: T) => ({ timestamp: getThreadSortTimestamp(thread, sortOrder), id: thread.id, }), ), );} 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 { return ( sortThreads( threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), sortOrder, )[0] ?? null );} // ── Pinned reorder: fractional index keys ──────────────────────────────// Pinned threads carry an optional pinOrderKey (a base-26 string). The// pinned block sorts keyed threads by plain string comparison, so a drag// (web) or Move up/down (mobile) writes ONE key to ONE thread on that// thread's own server — neighbors, possibly living on other servers, are// never touched, and every client connected to the same servers converges// on the same order.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 !== "") { // Recurse past the longest common prefix ("a" pads the shorter side). 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)); } // Consecutive leading digits: either b has spare digits to shorten into, // or we extend a (never producing a trailing minimum digit — the base // case midpoint("", "") is the middle of the alphabet). 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);} /** Evenly spaced keys for materializing an order. Wider keys keep a large active list from exhausting the space between two-digit keys. */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)); // Skip values whose low digit is the minimum (a trailing "a" key). 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;} /** * Assignments needed to realize a new pinned order. When the moved thread * sits between two keyed (or absent) neighbors, this is a single write to * the moved thread. When a neighbor is keyless (threads pinned before * reordering shipped), the whole section gets fresh spread keys — a * one-time materialization; every move after that is single-write. Active * reordering uses the same planner with activeOrderKey values. */export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ readonly orderedIds: readonly string[]; /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap<string, string | null | undefined>; 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 }]; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. 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 }]; });} /** * Pinned block order: user-arranged keys first (string comparison, id * tiebreak), then keyless threads newest-created first — so threads on * servers that predate reordering keep the static creation order at the * bottom of the block instead of breaking the section. */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[] { 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); }); keyless.sort((left, right) => { const leftMs = Date.parse(left.createdAt); const rightMs = Date.parse(right.createdAt); return ( (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || identityTiebreak(left, right) ); }); return [...keyed, ...keyless];} /** New and reopened threads lead the active list. Arranged threads follow their saved keys; activity leaves both groups in place. */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[] { 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 = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); } return ( order || left.id.localeCompare(right.id) || (left.environmentId ?? "").localeCompare(right.environmentId ?? "") ); });} /** * planPinnedReorder specialized for mobile's Move up / Move down menu * actions: swap the moved thread with its displayed neighbor. Null when the * move falls off either end of the list. Same single-write-per-move * semantics as a web drag. */export function planPinnedMove(input: { /** Reorder-capable pinned thread ids in displayed order. */ readonly orderedIds: readonly string[]; readonly keysById: ReadonlyMap<string, string | null | undefined>; readonly movedId: string; readonly direction: "up" | "down";}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null { const { orderedIds, keysById, movedId, direction } = input; const from = orderedIds.indexOf(movedId); if (from === -1) return null; const to = direction === "up" ? from - 1 : from + 1; if (to < 0 || to >= orderedIds.length) return null; const newOrder = [...orderedIds]; newOrder.splice(from, 1); newOrder.splice(to, 0, movedId); return planPinnedReorder({ orderedIds: newOrder, keysById, movedId });}
import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
export interface ThreadSortInput {
readonly createdAt: string;
readonly updatedAt: string;
readonly latestUserMessageAt?: 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<
OrchestrationThreadShell,
"settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
>;
/** The timestamp a settled row sorts and labels by on every client: settledAt
when stamped, otherwise the latest message or turn stamp, then updatedAt. */
export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null {
if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) {
return thread.settledAt;
}
let latest: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const candidate of [
thread.latestUserMessageAt,
thread.latestTurn?.requestedAt,
thread.latestTurn?.startedAt,
thread.latestTurn?.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;
}
function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
for (const value of values) {
const timestamp = toSortableTimestamp(value ?? undefined);
if (timestamp !== null) {
return timestamp;
}
}
return null;
}
function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
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<SidebarProjectSortOrder, "manual">,
): number {
if (sortOrder === "created_at") {
return (
getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
);
}
return getLatestUserMessageTimestamp(thread);
}
/**
* Sort anchor for the active thread list: creation time, re-anchored to
* unsettledAt when the thread last re-entered the active list (an explicit
* un-settle, or a settled thread waking on activity). The list stays static
* between lifecycle transitions, but an un-settled thread surfaces at the
* top instead of sinking back to its creation-order slot. Shared by web and
* mobile so both render the same order. Malformed timestamps sink to 0.
*/
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<T extends { readonly id: string } & ThreadSortInput>(
threads: readonly T[],
sortOrder: SidebarThreadSortOrder,
): T[] {
return Arr.sort(
threads,
Order.mapInput(
Order.Struct({
timestamp: Order.flip(Order.Number),
id: Order.flip(Order.String),
}),
(thread: T) => ({
timestamp: getThreadSortTimestamp(thread, sortOrder),
id: thread.id,
}),
),
);
}
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 {
return (
sortThreads(
threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
sortOrder,
)[0] ?? null
);
}
// ── Pinned reorder: fractional index keys ──────────────────────────────
// Pinned threads carry an optional pinOrderKey (a base-26 string). The
// pinned block sorts keyed threads by plain string comparison, so a drag
// (web) or Move up/down (mobile) writes ONE key to ONE thread on that
// thread's own server — neighbors, possibly living on other servers, are
// never touched, and every client connected to the same servers converges
// on the same order.
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 !== "") {
// Recurse past the longest common prefix ("a" pads the shorter side).
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));
}
// Consecutive leading digits: either b has spare digits to shorten into,
// or we extend a (never producing a trailing minimum digit — the base
// case midpoint("", "") is the middle of the alphabet).
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);
}
/** Evenly spaced keys for materializing an order. Wider keys keep a large
active list from exhausting the space between two-digit keys. */
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));
// Skip values whose low digit is the minimum (a trailing "a" key).
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;
}
/**
* Assignments needed to realize a new pinned order. When the moved thread
* sits between two keyed (or absent) neighbors, this is a single write to
* the moved thread. When a neighbor is keyless (threads pinned before
* reordering shipped), the whole section gets fresh spread keys — a
* one-time materialization; every move after that is single-write. Active
* reordering uses the same planner with activeOrderKey values.
*/
export function planPinnedReorder(input: {
/** Thread ids in the desired visual order (after the move). */
readonly orderedIds: readonly string[];
/** Include retained keys from hidden rows; only orderedIds receive writes. */
readonly keysById: ReadonlyMap<string, string | null | undefined>;
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 }];
}
// Keyless neighbor (or corrupt keys): rewrite the section in the new order.
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 }];
});
}
/**
* Pinned block order: user-arranged keys first (string comparison, id
* tiebreak), then keyless threads newest-created first — so threads on
* servers that predate reordering keep the static creation order at the
* bottom of the block instead of breaking the section.
*/
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[] {
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);
});
keyless.sort((left, right) => {
const leftMs = Date.parse(left.createdAt);
const rightMs = Date.parse(right.createdAt);
return (
(Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) ||
identityTiebreak(left, right)
);
});
return [...keyed, ...keyless];
}
/** New and reopened threads lead the active list. Arranged threads follow
their saved keys; activity leaves both groups in place. */
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[] {
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 = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left);
}
return (
order ||
left.id.localeCompare(right.id) ||
(left.environmentId ?? "").localeCompare(right.environmentId ?? "")
);
});
}
/**
* planPinnedReorder specialized for mobile's Move up / Move down menu
* actions: swap the moved thread with its displayed neighbor. Null when the
* move falls off either end of the list. Same single-write-per-move
* semantics as a web drag.
*/
export function planPinnedMove(input: {
/** Reorder-capable pinned thread ids in displayed order. */
readonly orderedIds: readonly string[];
readonly keysById: ReadonlyMap<string, string | null | undefined>;
readonly movedId: string;
readonly direction: "up" | "down";
}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null {
const { orderedIds, keysById, movedId, direction } = input;
const from = orderedIds.indexOf(movedId);
if (from === -1) return null;
const to = direction === "up" ? from - 1 : from + 1;
if (to < 0 || to >= orderedIds.length) return null;
const newOrder = [...orderedIds];
newOrder.splice(from, 1);
newOrder.splice(to, 0, movedId);
return planPinnedReorder({ orderedIds: newOrder, keysById, movedId });
}