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
@@ -483,4 +483,47 @@ describe("LaneConsolePane", () => {
expect(screen.queryByText("shown")).toBeNull();
});
});
it("resumes a session handed over by the Sessions tab, once per request", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ run_id: "agenthub-lane-1" });
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "agenthub-lane-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: "s-1",
sessionId: null,
startedAt: null,
promptPreview: null,
});
const request = { sessionId: "s-1", model: "claude-opus-5-5", nonce: 1 };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={request} />
</MemoryRouter>
);
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({
slot: 0,
prompt: "",
resumeSessionId: "s-1",
model: "claude-opus-5-5",
})
)
);
// A re-render with the same request is not a second resume.
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={{ ...request }} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.lanes.action).toHaveBeenCalledTimes(1);
});
});