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,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();
});
});