c8bc4a95a9
Workspace groups: named, foldable, ordered sidebar sections kept per account (agenthub.workspace.groups); a workspace joins one from its menu, a drop on the header, a drag under a row, or Alt+Up/Down past a group's edge; headers drag to reorder; a folded header shows its most urgent state; New workspace in this group; fold/unfold all. Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the Changes panel shows a parent folder's repos only when Settings -> Git -> Changes panel turns it on (agenthub.changes.innerRepos, off by default). Cost: the hook writer adds a session's sub-agent transcripts to its tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the session's own model was never counted live (~8% of a week here); an imported session is re-raised on every pass, so a claude -p run first seen with an empty transcript no longer stays at $0 (~10%). A one-time raise-only catch-up (user_version 5) re-reads ~/.claude/projects. Also: omp install script may redirect to its exact install.redirect URL (#85, repo-links test exempts it), native paste in the terminal.
222 lines
7.9 KiB
TypeScript
222 lines
7.9 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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<string, string>;
|
||
}
|
||
|
||
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<WorkspaceGroups>;
|
||
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<string, string> = {};
|
||
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<T extends { id: number }>(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);
|
||
}
|