/** * @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend", * "Client A", …): their names, order and fold state, and which group each * workspace sits in. One JSON value under `agenthub.workspace.groups`, one of * the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so * the groups are kept per ACCOUNT on the server and follow it to any browser, * machine or port, and through updates. Like the drag order, it is a viewer's * arrangement, not a workspace fact: nothing about a lane changes, the CLI and * `GET /api/lanes` know nothing of it, and deleting a group only ungroups its * workspaces. A `useSyncExternalStore` reader cached on the raw string, so * every reader agrees; it re-reads when the account's preferences are loaded * (`PREFS_EVENT`) and when another tab writes. * @author Nguyễn Ngọc Trí Vĩ */ import { useSyncExternalStore } from "react"; export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups"; /** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */ export const PREFS_EVENT = "agenthub:prefs"; export interface WorkspaceGroup { id: string; name: string; /** Folded shut in the sidebar. */ folded?: boolean; } export interface WorkspaceGroups { /** In display order. */ groups: WorkspaceGroup[]; /** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */ of: Record; } const EMPTY: WorkspaceGroups = { groups: [], of: {} }; export const MAX_GROUP_NAME = 60; const listeners = new Set<() => void>(); let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY }; function readRaw(): string | null { try { return localStorage.getItem(WORKSPACE_GROUPS_KEY); } catch { return null; } } function parse(raw: string | null): WorkspaceGroups { if (!raw) return EMPTY; try { const v = JSON.parse(raw) as Partial; const groups = (Array.isArray(v.groups) ? v.groups : []).filter( (g): g is WorkspaceGroup => !!g && typeof g.id === "string" && !!g.id && typeof g.name === "string" ); const ids = new Set(groups.map((g) => g.id)); const of: Record = {}; if (v.of && typeof v.of === "object") { for (const [lane, gid] of Object.entries(v.of)) { if (typeof gid === "string" && ids.has(gid)) of[lane] = gid; } } return { groups, of }; } catch { return EMPTY; } } export function readWorkspaceGroups(): WorkspaceGroups { const raw = readRaw(); if (raw === cache.raw) return cache.value; cache = { raw, value: parse(raw) }; return cache.value; } function write(next: WorkspaceGroups): void { try { localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next)); } catch { /* disabled storage: the groups live until the page closes */ cache = { raw: JSON.stringify(next), value: next }; } listeners.forEach((l) => l()); } /** Change the groups through `fn` (given the current value, never mutated). */ export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void { write(fn(readWorkspaceGroups())); } const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME); /** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */ export function createGroup(name: string, laneIds: number[] = []): string { const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; updateWorkspaceGroups((cur) => { const of = { ...cur.of }; for (const lane of laneIds) of[lane] = id; return { groups: [...cur.groups, { id, name: cleanName(name) }], of }; }); return id; } /** Rename; a blank name keeps the old one. */ export function renameGroup(id: string, name: string): void { const clean = cleanName(name); if (!clean) return; updateWorkspaceGroups((cur) => ({ ...cur, groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)), })); } /** Delete the group; its workspaces become ungrouped (nothing else changes). */ export function deleteGroup(id: string): void { updateWorkspaceGroups((cur) => ({ groups: cur.groups.filter((g) => g.id !== id), of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)), })); } export function setGroupFolded(id: string, folded: boolean): void { updateWorkspaceGroups((cur) => ({ ...cur, groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)), })); } /** Move the group `by` places (−1 up, +1 down), clamped. */ export function moveGroup(id: string, by: number): void { updateWorkspaceGroups((cur) => { const from = cur.groups.findIndex((g) => g.id === id); const to = Math.max(0, Math.min(cur.groups.length - 1, from + by)); if (from < 0 || from === to) return cur; const groups = [...cur.groups]; groups.splice(to, 0, ...groups.splice(from, 1)); return { ...cur, groups }; }); } /** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */ export function moveGroupBefore(id: string, beforeId: string | null): void { updateWorkspaceGroups((cur) => { const moving = cur.groups.find((g) => g.id === id); if (!moving || id === beforeId) return cur; const rest = cur.groups.filter((g) => g.id !== id); const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId); const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)]; if (groups.every((g, i) => g === cur.groups[i])) return cur; return { ...cur, groups }; }); } /** Fold (or unfold) every group at once. */ export function setAllFolded(folded: boolean): void { updateWorkspaceGroups((cur) => ({ ...cur, groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })), })); } /** Put a workspace in a group, or (null) take it out of every group. */ export function assignGroup(laneId: number, groupId: string | null): void { updateWorkspaceGroups((cur) => { const of = { ...cur.of }; if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId; else delete of[laneId]; return { ...cur, of }; }); } /** Forget the lanes that no longer exist, so the stored value stays small. */ export function pruneWorkspaceGroups(laneIds: number[]): void { const cur = readWorkspaceGroups(); const live = new Set(laneIds.map(String)); const stale = Object.keys(cur.of).filter((k) => !live.has(k)); if (!stale.length) return; write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) }); } /** * `lanes` (already in the stored drag order) arranged by group: each group's * workspaces in group order, keeping their relative order, then the * ungrouped ones. Stable, never mutates. */ export function arrangeByGroup(lanes: T[], value: WorkspaceGroups): T[] { const rank = new Map(value.groups.map((g, i) => [g.id, i])); const at = (l: T) => { const gid = value.of[l.id]; return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length; }; return lanes .map((l, i) => ({ l, i })) .sort((a, b) => at(a.l) - at(b.l) || a.i - b.i) .map((x) => x.l); } /** The group a workspace is in, or null. */ export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null { const gid = value.of[laneId]; return (gid && value.groups.find((g) => g.id === gid)) || null; } function subscribe(l: () => void) { listeners.add(l); const onStorage = (e: StorageEvent) => { if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l(); }; window.addEventListener("storage", onStorage); window.addEventListener(PREFS_EVENT, l); return () => { listeners.delete(l); window.removeEventListener("storage", onStorage); window.removeEventListener(PREFS_EVENT, l); }; } export function useWorkspaceGroups(): WorkspaceGroups { return useSyncExternalStore(subscribe, readWorkspaceGroups); }