/** * @file laneOrderStorage.ts * @description Persists the sidebar's user-chosen workspace order to * localStorage, the same read/validate/fallback convention as useTheme.ts, and * holds the drag-to-reorder math the sidebar and the console tab bar share * (the tab bar's own order lives with its tabs, `lib/paneLayout.ts`). * * The order lives in the browser rather than the lanes table on purpose: it is * a per-viewer preference, and nothing * server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an * opinion about it. Ceiling: the order does not follow the user to another * browser or to the CLI — move it to a `lanes.sort_order` column if it ever * must. * * @author Nguyễn Ngọc Trí Vĩ */ const STORAGE_KEY = "agenthub.workspace.laneOrder"; export function readLaneOrder(): number[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((id): id is number => typeof id === "number"); } catch { return []; } } export function writeLaneOrder(ids: number[]): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); } catch { /* ignore quota / disabled storage */ } } /** * Applies a stored id order to a freshly fetched lane list. * * Ids the stored order never saw — a lane created since, or created in another * tab — keep their server order (id ascending) and land at the END, so a new * lane appears predictably rather than wherever a stale index puts it. Stored * ids naming a lane that no longer exists are simply skipped. * * @param lanes Lanes in server order. * @param order Stored id order. * @returns A new array; the input is never mutated. */ export function sortByStoredOrder(lanes: T[], order: number[]): T[] { const rank = new Map(order.map((id, i) => [id, i])); const known = lanes .filter((l) => rank.has(l.id)) .sort((a, b) => rank.get(a.id)! - rank.get(b.id)!); const fresh = lanes.filter((l) => !rank.has(l.id)); return [...known, ...fresh]; } /** * Moves the lane at `from` to index `to`, returning the resulting id order. * Out-of-range indices yield the unchanged order rather than a hole. */ export function moveLane(ids: number[], from: number, to: number): number[] { if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids; const next = [...ids]; next.splice(to, 0, ...next.splice(from, 1)); return next; } /** Where a drop lands: before the first item whose middle (`mids`, on the * drag axis) lies past the pointer, else at the end. Gaps between items — * a workspace's session list, the space between two tabs — count too. */ export function insertionIndex(mids: number[], pos: number): number { const i = mids.findIndex((m) => pos < m); return i === -1 ? mids.length : i; } /** Moves the item at `from` to just before insertion point `before`. */ export function moveBefore(ids: T[], from: number, before: number): T[] { if (from < 0 || from >= ids.length || before < 0 || before > ids.length) return ids; if (before === from || before === from + 1) return ids; const next = [...ids]; next.splice(before > from ? before - 1 : before, 0, ...next.splice(from, 1)); return next; }