feat(ui): finish the workspace-sessions plan — tool filter, session search, explicit Resume

- Sidebar: a tool filter (shown once workspaces of two tools exist, kept in
  agenthub.sidebar.projects → tool; reordering under it keeps the hidden
  workspaces' places), and search that also finds a workspace by one of its
  session names, showing only the matching sessions.
- Workspace home: the repository it belongs to (root, main branch, worktree
  count) under the title, and each recent's cost.
- Transcript page: an explicit Resume for a Claude session started in a
  workspace's own folder (SessionResumeButton). It warns that a resume re-reads
  the whole transcript, re-reads the live panes, then starts in the first free
  pane and opens it; a live session offers Open pane instead.
This commit is contained in:
2026-10-02 01:35:34 +07:00
parent d0535af626
commit 5b269698f6
15 changed files with 597 additions and 36 deletions
@@ -190,6 +190,64 @@ describe("WorkspaceTree", () => {
expect(rowIds()).toEqual(["workspace-row-3"]);
});
it("filters by tool, remembered, and offers the filter only with two tools", async () => {
const user = userEvent.setup();
const { unmount } = renderTree();
await screen.findByTestId("workspace-row-1");
await user.selectOptions(screen.getByLabelText("Filter by tool"), "codex");
expect(rowIds()).toEqual(["workspace-row-3"]);
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).tool).toBe("codex");
await user.selectOptions(screen.getByLabelText("Filter by tool"), "");
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-2", "workspace-row-3"]);
unmount();
// One tool only: no filter, and a stale stored one is ignored.
localStorage.setItem("agenthub.sidebar.projects", JSON.stringify({ tool: "codex" }));
lanes = lanes.filter((l) => l.tool === "claude");
renderTree();
await screen.findByTestId("workspace-row-1");
expect(screen.queryByLabelText("Filter by tool")).toBeNull();
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-2"]);
});
it("reorders within a tool filter without losing the hidden workspaces' places", async () => {
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, 3, 2]));
localStorage.setItem("agenthub.sidebar.projects", JSON.stringify({ tool: "claude" }));
renderTree();
fireEvent.keyDown(await screen.findByTestId("workspace-open-2"), {
key: "ArrowUp",
altKey: true,
});
expect(JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!)).toEqual([2, 3, 1]);
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-1"]);
});
it("finds a workspace by one of its session names, showing only the matches", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
sessions:
(params as { cwd?: string }).cwd === "/r/sub"
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
: [session("s-live", "Live one"), session("s-old", "Old one")],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByRole("button", { name: "Search workspaces and sessions" }));
await user.type(screen.getByRole("searchbox"), "login");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
expect(screen.getByTestId("workspace-session-s-x")).toBeVisible();
expect(screen.queryByTestId("workspace-session-s-y")).toBeNull();
// A workspace whose NAME matches keeps all of its sessions.
await user.clear(screen.getByRole("searchbox"));
await user.type(screen.getByRole("searchbox"), "sub");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
expect(screen.getByTestId("workspace-session-s-y")).toBeInTheDocument();
});
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
const user = userEvent.setup();
renderTree();