feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces
The Workspace page's lane strip moves into the sidebar as a project tree: workspaces grouped by git repository (new GET /api/projects, cached in memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline rename, drag/Alt+arrow ordering and a right-click row menu. The selected workspace is the URL's ?lane=, one shared lane list feeds the tree, the new bottom status bar and the page. The Changes panel gains an activity bar (Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
@@ -404,6 +404,7 @@ import type {
|
||||
LaneFileContent,
|
||||
LaneTreeEntry,
|
||||
LaneCounts,
|
||||
Project,
|
||||
LaneGitFacts,
|
||||
LaneRuntime,
|
||||
LaneTool,
|
||||
@@ -2416,6 +2417,14 @@ export const api = {
|
||||
}>("/remote-sources/sync-all", { method: "POST" }),
|
||||
},
|
||||
|
||||
/** Lanes grouped by git repo, with each repo's worktrees (server/lib/projects.js). */
|
||||
projects: {
|
||||
/** GET /api/projects — read on demand by the sidebar tree, never polled. */
|
||||
list: () => request<{ projects: Project[] }>("/projects"),
|
||||
/** POST /api/projects/refresh — drop the cached roots/worktree lists, then list. */
|
||||
refresh: () => request<{ projects: Project[] }>("/projects/refresh", { method: "POST" }),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── Lanes API ────────────────────────────────
|
||||
/** Agent work lanes: durable units of parallel work, one cwd per lane.
|
||||
* Maps to `server/routes/lanes.js`. */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file projectTreePrefs — the sidebar project tree's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which projects are
|
||||
* collapsed, whether a repo's untracked worktrees are listed, and which
|
||||
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
|
||||
* LANES_ROOT) are hidden. A `useSyncExternalStore` reader that caches nothing
|
||||
* but the parsed blob for the current raw string, so the sidebar and Settings
|
||||
* can never disagree. Nothing here reaches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const PROJECT_TREE_KEY = "agenthub.sidebar.projects";
|
||||
|
||||
export interface ProjectTreePrefs {
|
||||
collapsed: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
}
|
||||
|
||||
const DEFAULTS: ProjectTreePrefs = { collapsed: {}, showUntracked: true, hideSources: [] };
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
|
||||
|
||||
function readRaw(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(PROJECT_TREE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
const raw = readRaw();
|
||||
if (raw === cache.raw) return cache.value;
|
||||
let value = DEFAULTS;
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<ProjectTreePrefs>) : {};
|
||||
value = {
|
||||
collapsed: parsed.collapsed && typeof parsed.collapsed === "object" ? parsed.collapsed : {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
};
|
||||
} catch {
|
||||
value = DEFAULTS;
|
||||
}
|
||||
cache = { raw, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
|
||||
try {
|
||||
localStorage.setItem(PROJECT_TREE_KEY, JSON.stringify({ ...readProjectTreePrefs(), ...patch }));
|
||||
} catch {
|
||||
/* disabled storage: the tree just forgets between visits */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
|
||||
export function useProjectTreePrefs(): ProjectTreePrefs {
|
||||
return useSyncExternalStore(subscribe, readProjectTreePrefs);
|
||||
}
|
||||
@@ -2788,6 +2788,27 @@ export type LaneChanges =
|
||||
}
|
||||
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
|
||||
|
||||
/** One worktree of a project's repo (`git worktree list`), for the sidebar. */
|
||||
export interface ProjectWorktree {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
/** The repo's main worktree (git lists it first). */
|
||||
main: boolean;
|
||||
/** The lane sitting exactly on this path, or null: an untracked worktree. */
|
||||
workspace_id: number | null;
|
||||
/** `claude` under `<root>/.claude/worktrees/`, `agenthub` under LANES_ROOT. */
|
||||
source?: "claude" | "agenthub";
|
||||
}
|
||||
|
||||
/** Lanes grouped by repo (`GET /api/projects`); `root: null` is "Folders". */
|
||||
export interface Project {
|
||||
root: string | null;
|
||||
name: string;
|
||||
/** Lane ids, in id order. */
|
||||
workspaces: number[];
|
||||
worktrees: ProjectWorktree[];
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
Reference in New Issue
Block a user