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
+31 -27
View File
@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
@@ -284,9 +284,27 @@ async function settle() {
});
}
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [params, setParams] = useSearchParams();
return (
<>
<span data-testid="lane-param">{params.get("lane")}</span>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
@@ -329,11 +347,12 @@ afterEach(() => {
});
describe("Workspace page — lane integration", () => {
it("lane strip lists the lanes the API returned", async () => {
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
expect(screen.queryByTestId("lane-strip")).toBeNull();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-param").textContent).toBe("1");
});
it("selecting a lane switches the pipeline and run id", async () => {
@@ -345,20 +364,16 @@ describe("Workspace page — lane integration", () => {
await renderWorkspace();
const user = userEvent.setup();
// Selection is announced on the tile itself, not inferred from styling.
const laneATile = screen.getByTestId("lane-tile-1");
const laneBTile = screen.getByTestId("lane-tile-2");
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
await user.click(laneBTile);
// The sidebar names the workspace in the URL; the page follows it.
await user.click(screen.getByTestId("pick-2"));
await settle();
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
// And the detail panel follows: only the selected lane gets a full card.
// Only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
@@ -490,27 +505,17 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("opens the add-lane modal from the strip's first tile", async () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => {
const user = userEvent.setup();
lanesToReturn = [];
await renderWorkspace();
// The tile leads the strip, so it is reachable without scrolling past lanes.
const strip = screen.getByTestId("lane-strip");
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
await user.click(screen.getByTestId("lane-tile-add"));
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
});
it("gives every lane a tile in the carousel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
await renderWorkspace();
// Lane 1 is selected by default. Its full card — controls, git facts — is
@@ -555,7 +560,6 @@ describe("Workspace — the console is its own section", () => {
// The console is a window onto a process, not a property of a card. Nesting
// it in one made the owning card span the row and left a hole when closed.
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
});
it("has no header line of its own - the pipeline panel already names the lane", async () => {