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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user