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
@@ -0,0 +1,240 @@
/**
* @file ProjectTree.test.tsx
* @description Tests for the sidebar's project → workspace tree: grouping by
* repo with Folders last, the primary badge, untracked worktrees with Adopt and
* the hidden count, collapse persistence, Alt+arrow reordering kept in
* localStorage, double-click rename, the row menu's items by kind and tool, and
* the status dot (`workspaceDot`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
function lane(id: number, cwd: string, extra: Partial<Lane> = {}): Lane {
return {
id,
title: "",
cwd,
branch: null,
kind: "adopted",
tool: "claude",
router_profile: null,
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
needs_action: null,
liveness: "idle",
pipeline_nodes: [],
...extra,
} as unknown as Lane;
}
let lanes: Lane[] = [];
let projects: Project[] = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn(async () => ({ lanes, counts: {} })),
update: vi.fn(async () => ({ lane: lanes[0] })),
ensure: vi.fn(async () => ({ lane: lane(9, "/r/wt"), created: true })),
action: vi.fn(async () => ({ ok: true })),
routerList: vi.fn(async () => ({ routers: [] })),
},
projects: {
list: vi.fn(async () => ({ projects })),
refresh: vi.fn(async () => ({ projects })),
},
run: { cwds: vi.fn(async () => ({ items: [] })) },
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
iconUrl: () => "",
},
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
},
};
});
import { api } from "../../lib/api";
import { ProjectTree } from "../ProjectTree";
import { workspaceDot } from "../lanes/WorkspaceRow";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree(at = "/") {
return render(
<MemoryRouter initialEntries={[at]}>
<Routes>
<Route
path="*"
element={
<>
<ProjectTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
lanes = [
lane(1, "/r", { branch: "main" }),
lane(2, "/r/sub", { title: "Sub" }),
lane(3, "/tmp/plain", { kind: "managed", tool: "codex" }),
];
projects = [
{
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
{
path: "/r/.claude/worktrees/x",
branch: "wf-x",
main: false,
workspace_id: null,
source: "claude",
},
{ path: "/elsewhere", branch: "feat", main: false, workspace_id: null },
],
},
{ root: null, name: "Folders", workspaces: [3], worktrees: [] },
];
});
describe("ProjectTree", () => {
it("groups workspaces under their repo, Folders last, with the primary badge", async () => {
renderTree();
const repo = await screen.findByTestId("project-r");
expect(within(repo).getByTestId("workspace-row-1")).toBeInTheDocument();
expect(within(repo).getByTestId("workspace-row-2")).toBeInTheDocument();
expect(within(repo).getByText("primary")).toBeInTheDocument();
const folders = screen.getByTestId("project-folders");
expect(within(folders).getByTestId("workspace-row-3")).toBeInTheDocument();
expect(repo.compareDocumentPosition(folders) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("lists untracked worktrees with Adopt, and adopting selects the new workspace", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("project-r");
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
await user.click(screen.getAllByRole("button", { name: "Adopt" })[1]!);
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/elsewhere",
tool: "claude",
exact: true,
});
await waitFor(() => expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=9"));
});
it("hides a hidden source and says how many, and reveals them on click", async () => {
const user = userEvent.setup();
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], showUntracked: true, collapsed: {} })
);
renderTree();
await screen.findByTestId("project-r");
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(1);
await user.click(screen.getByText("1 hidden"));
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
});
it("remembers a collapsed project", async () => {
const user = userEvent.setup();
renderTree();
const repo = await screen.findByTestId("project-r");
await user.click(within(repo).getByRole("button", { name: "r" }));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsed).toEqual({
"/r": true,
});
});
it("selects a workspace by URL and marks the current one", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
const row = await screen.findByTestId("workspace-row-2");
expect(within(row).getAllByRole("button")[0]).toHaveAttribute("aria-current", "true");
await user.click(within(screen.getByTestId("workspace-row-1")).getAllByRole("button")[0]!);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
});
it("reorders within a project with Alt+ArrowUp and keeps the order in localStorage", async () => {
renderTree();
const row2 = await screen.findByTestId("workspace-row-2");
fireEvent.keyDown(within(row2).getAllByRole("button")[0]!, { key: "ArrowUp", altKey: true });
const order = JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!);
expect(order.slice(0, 2)).toEqual([2, 1]);
const ids = within(screen.getByTestId("project-r"))
.getAllByTestId(/^workspace-row-/)
.map((el) => el.dataset.testid);
expect(ids).toEqual(["workspace-row-2", "workspace-row-1"]);
});
it("renames on double-click + Enter, and Escape cancels without a call", async () => {
const user = userEvent.setup();
renderTree();
const row = await screen.findByTestId("workspace-row-2");
await user.dblClick(within(row).getAllByRole("button")[0]!);
const input = screen.getByTestId("workspace-rename-2");
await user.clear(input);
await user.type(input, " Renamed {Enter}");
expect(api.lanes.update).toHaveBeenCalledWith(2, { title: "Renamed" });
vi.mocked(api.lanes.update).mockClear();
await user.dblClick(within(screen.getByTestId("workspace-row-2")).getAllByRole("button")[0]!);
await user.type(screen.getByTestId("workspace-rename-2"), "x{Escape}");
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-menu-1"));
let menu = screen.getByTestId("workspace-row-menu-1");
expect(within(menu).queryByText("Reset worktree…")).toBeNull();
expect(within(menu).getByText("Create/update readme file")).toBeInTheDocument();
expect(within(menu).getByText("Delete workspace…")).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByTestId("workspace-menu-3"));
menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
expect(within(menu).queryByText("Create/update readme file")).toBeNull();
});
});
describe("workspaceDot", () => {
it("maps needs-you, dead/error, running, active and idle", () => {
expect(workspaceDot(lane(1, "/a", { needs_action: "x", status: "running" }))).toBe(
"bg-status-warning"
);
expect(workspaceDot(lane(1, "/a", { liveness: "dead" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "error" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow");
expect(workspaceDot(lane(1, "/a", { liveness: "active" }))).toBe("bg-status-success");
expect(workspaceDot(lane(1, "/a"))).toBe("bg-surface-4");
});
});
@@ -14,7 +14,13 @@ import { eventBus } from "../../lib/eventBus";
function renderSidebar(wsConnected: boolean, collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={() => {}} />
<Sidebar
wsConnected={wsConnected}
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
}
@@ -45,22 +51,24 @@ describe("Sidebar", () => {
expect(screen.queryByText("Activity Feed")).toBeNull();
});
it('should show "Live" when WebSocket is connected', () => {
// The live/version line moved to the status bar (StatusBar.test.tsx); the
// rail keeps an icon-only button for the connection details.
it("keeps the connection button in the rail only", () => {
renderSidebar(true);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.queryByText("Live")).toBeNull();
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
});
it('should show "Disconnected" when WebSocket is not connected', () => {
renderSidebar(false);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
it("offers the connection details from the collapsed rail", () => {
renderSidebar(false, true);
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
});
it("should show version number", () => {
// `__APP_VERSION__` is injected by Vite from the repo-root package.json
// (see vite.config.ts) and replaced at transform time in tests too, so this
// stays correct as the project version changes.
it("renders the project tree when expanded and its icon in the rail", () => {
renderSidebar(true);
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
expect(screen.getByTestId("project-tree")).toBeInTheDocument();
renderSidebar(true, true);
expect(screen.getAllByRole("button", { name: "Projects" }).length).toBeGreaterThan(0);
});
it("should have correct navigation hrefs", () => {
@@ -13,7 +13,13 @@ import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
}
@@ -0,0 +1,64 @@
/**
* @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, and today's spend from the
* pricing cost call's `daily_costs`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
function today() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
vi.mock("../../lib/api", () => ({
api: {
lanes: {
list: vi.fn().mockResolvedValue({
lanes: [
{ id: 1, status: "running", needs_action: null },
{ id: 2, status: "running", needs_action: "permission" },
{ id: 3, status: "idle", needs_action: null },
],
counts: {},
}),
},
pricing: {
totalCost: vi.fn().mockImplementation(async () => ({
total_cost: 10,
breakdown: [],
daily_costs: [
{ date: "2000-01-01", cost: 9 },
{ date: today(), cost: 1.234 },
],
})),
},
},
}));
import { StatusBar } from "../StatusBar";
describe("StatusBar", () => {
it("shows the connection with the version, the counts and today's spend", async () => {
const user = userEvent.setup();
const onConnectionClick = vi.fn();
render(<StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(screen.getByText("Live"));
expect(onConnectionClick).toHaveBeenCalled();
});
it("says Disconnected when the socket is down", () => {
render(<StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
});