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
@@ -13,6 +13,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
@@ -130,6 +131,23 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
</>
);
}
describe("LaneChangesPanel", () => {
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
render(<LaneChangesPanel laneId={7} />);
@@ -553,7 +571,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
}
);
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -589,7 +607,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
};
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -617,7 +635,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -633,7 +651,7 @@ describe("LaneChangesPanel", () => {
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -664,7 +682,7 @@ describe("LaneChangesPanel", () => {
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -697,7 +715,7 @@ describe("LaneChangesPanel", () => {
lines: ["partial"],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -723,7 +741,7 @@ describe("LaneChangesPanel", () => {
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -871,17 +889,12 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files first in the mode row", async () => {
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
screen.getByRole("button", { name })
);
// Source order is the reading order here, so comparing positions pins it.
for (let i = 1; i < modes.length; i += 1) {
expect(
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1142,13 +1155,13 @@ describe("LaneChangesPanel", () => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
const { rerender } = render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<LaneChangesPanel laneId={8} />);
rerender(<Tabbed laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -1170,7 +1183,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
@@ -1208,7 +1221,7 @@ describe("LaneChangesPanel", () => {
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
@@ -1250,7 +1263,7 @@ describe("LaneChangesPanel", () => {
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
@@ -1332,13 +1345,13 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
});
const { rerender } = render(<LaneChangesPanel laneId={7} />);
const { rerender } = render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
rerender(<LaneChangesPanel laneId={8} />);
rerender(<Tabbed laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
@@ -1,195 +0,0 @@
/**
* @file Rendering tests for the Workspace lane tile: the amber "detected"
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
* declared one, including when the declaration used a node alias — which is
* most of what the ship-feature skill declares), and the reorder handles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import LaneStripCard from "../LaneStripCard";
import type { Lane, LaneNode } from "../../../lib/types";
/** Four nodes of the ship-feature shape: `tests` is an ALIAS of `gates`. */
function nodes(currentId: string): LaneNode[] {
return (["intake", "implementing", "gates", "review"] as const).map((id) => ({
id,
label: id,
icon: "",
gate: false,
state: id === currentId ? "current" : "pending",
}));
}
function lane(overrides: Partial<Lane>): Lane {
return {
id: 1,
title: "lane",
cwd: "/tmp/lane",
branch: null,
kind: "adopted",
tool: "claude",
source_repo: null,
pipeline: "ship-feature",
session_id: null,
run_id: null,
stage: "gates",
stage_since: null,
status: "running",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Ship feature",
pipeline_nodes: nodes("gates"),
progress: 50,
stage_seconds: null,
last_event_seconds: null,
liveness: "active",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
...overrides,
} as Lane;
}
describe("LaneStripCard detected badge", () => {
it("badges a detection that is ahead of the declaration", () => {
render(
<LaneStripCard
lane={lane({ detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
});
it("stays silent when the declaration was made under an alias and the detection is behind it", () => {
// `tests` is an alias of the `gates` node — not a node id. Locating the
// declaration by id would score it -1 and badge this stale `implementing`
// detection as though the lane had moved backwards.
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "implementing" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
});
it("still badges a detection ahead of an alias declaration", () => {
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
});
});
describe("LaneStripCard reorder", () => {
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
function dataTransfer(initial = "") {
let payload = initial;
return {
setData: (_type: string, v: string) => {
payload = v;
},
getData: () => payload,
effectAllowed: "",
dropEffect: "",
};
}
it("reports the dragged tile's index and the drop target's", () => {
const onReorder = vi.fn();
const dt = dataTransfer();
render(
<>
<LaneStripCard
lane={lane({ id: 1 })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
<LaneStripCard
lane={lane({ id: 2 })}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
</>
);
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
expect(onReorder).toHaveBeenCalledWith(0, 2);
});
it("ignores a drop carrying no tile index", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
expect(onReorder).not.toHaveBeenCalled();
});
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
const tile = screen.getByTestId("lane-tile-1");
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 1);
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 3);
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
fireEvent.keyDown(tile, { key: "ArrowLeft" });
expect(onReorder).toHaveBeenCalledTimes(2);
});
it("shows the id without the 'Lane' prefix", () => {
render(
<LaneStripCard
lane={lane({ id: 7, title: "checkout flow" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
const tile = screen.getByTestId("lane-tile-7");
expect(tile).toHaveTextContent("#7");
expect(tile.textContent).not.toMatch(/Lane/i);
});
});
@@ -0,0 +1,90 @@
/**
* @file WorkspaceSessionsTab.test.tsx
* @description Tests for the Workspace right panel's Sessions tab: it lists
* the workspace's own sessions (cwd + tool), filters them client-side, hands a
* claude session to Resume, and offers no Resume on another tool's workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
sessions: { list: vi.fn() },
aiTools: { list: vi.fn().mockResolvedValue({ tools: [], default: "claude" }) },
},
}));
import { api } from "../../../lib/api";
import { WorkspaceSessionsTab } from "../WorkspaceSessionsTab";
const session = (id: string, name: string): Session =>
({
id,
name,
status: "completed",
cwd: "/w",
model: "claude-opus-5-5",
started_at: "2026-10-01T00:00:00Z",
ended_at: null,
metadata: null,
cost: 1.5,
tool: "claude",
}) as Session;
const lane = (tool: string) => ({ id: 1, cwd: "/w", tool }) as unknown as Lane;
beforeEach(() => {
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [session("s-1", "Fix login"), session("s-2", "Add tests")],
} as never);
});
function renderTab(tool: string, onResume = vi.fn()) {
render(
<MemoryRouter>
<WorkspaceSessionsTab lane={lane(tool)} onResume={onResume} />
</MemoryRouter>
);
return onResume;
}
describe("WorkspaceSessionsTab", () => {
it("lists the workspace's sessions by cwd and tool, and filters them", async () => {
const user = userEvent.setup();
renderTab("claude");
expect(await screen.findByText("Fix login")).toBeInTheDocument();
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/w", tool: "claude", limit: 50 });
await user.type(screen.getByRole("searchbox"), "tests");
expect(screen.queryByText("Fix login")).toBeNull();
expect(screen.getByText("Add tests")).toBeInTheDocument();
expect(screen.getAllByRole("link", { name: "Open" })[0]).toHaveAttribute(
"href",
"/sessions/s-2"
);
});
it("hands the session to Resume", async () => {
const user = userEvent.setup();
const onResume = renderTab("claude");
await screen.findByText("Fix login");
await user.click(screen.getAllByRole("button", { name: "Resume" })[0]!);
expect(onResume).toHaveBeenCalledWith(expect.objectContaining({ id: "s-1" }));
});
it("offers no Resume on a non-claude workspace", async () => {
renderTab("codex");
await screen.findByText("Fix login");
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
});
it("says so when the workspace has no session yet", async () => {
vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [] } as never);
renderTab("claude");
expect(await screen.findByText("No sessions in this workspace yet")).toBeInTheDocument();
});
});