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:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
+9
View File
@@ -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`. */
+68
View File
@@ -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);
}
+21
View File
@@ -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;