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
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
@@ -179,9 +179,24 @@ beforeEach(() => {
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{lanesToReturn.map((l) => (
<button key={l.id} onClick={() => setParams({ lane: String(l.id) })}>
{`pick ${l.title}`}
</button>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
@@ -191,11 +206,9 @@ async function renderWorkspace() {
return utils;
}
/** Click the strip tile for a lane, which is how a lane is selected. */
/** Pick a lane the way the sidebar does: by naming it in the URL. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
const tile = screen.getAllByText(title)[0];
if (!tile) throw new Error(`no strip tile for ${title}`);
await user.click(tile);
await user.click(screen.getByRole("button", { name: `pick ${title}` }));
await settle();
}