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