feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup

Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7):
- sessions.lane_id: a workspace lists only the sessions its panes held,
  stamped by bindRunSession (first writer wins, nested `claude -p`
  excluded); GET /api/sessions?lane=&archived=
- session row state dot, double-click rename, archive (sessions.archived_at,
  hides only); the activity bar's duplicate Sessions tab removed
- Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live
  session opens it in a second pane, a diff line's comment is pasted into
  the workspace's last-focused pane (lib/panePaste.ts)

dashboard_runs stability:
- a pane's previous run is archived as <pane>@<started_at> instead of being
  overwritten by INSERT OR REPLACE
- a run is flagged abandoned only once its tmux session is gone, at boot and
  on the run-heal beat (panes outlive a server restart)

UI:
- sidebar: no footer, language or theme block; small collapse button in the
  brand corner; version + update check in the connection details modal, a
  status-bar dot while a release is out; language in Settings -> General
- one Switch component for every switch, the themed Checkbox for every
  checkbox (no native checkboxes)

Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes
tested through fixtures), and the workspace plans under docs/plans/ (the
multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
@@ -1,24 +1,28 @@
/**
* @file Sidebar.test.tsx
* @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking.
* @description Unit tests for the Sidebar: the brand, the navigation links, the small collapse button in the brand's corner, no footer / language / theme controls (those moved to the status bar and Settings), and the connection details modal carrying the version and the release check.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
import { eventBus } from "../../lib/eventBus";
function renderSidebar(wsConnected: boolean, collapsed = false) {
function renderSidebar(
wsConnected: boolean,
collapsed = false,
extra: { onToggle?: () => void; connectionOpen?: boolean } = {}
) {
return render(
<MemoryRouter>
<Sidebar
wsConnected={wsConnected}
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onToggle={extra.onToggle ?? (() => {})}
connectionOpen={extra.connectionOpen ?? false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
@@ -51,17 +55,29 @@ describe("Sidebar", () => {
expect(screen.queryByText("Activity Feed")).toBeNull();
});
// The live/version line moved to the status bar (StatusBar.test.tsx); the
// rail keeps an icon-only button for the connection details.
it("keeps the connection button in the rail only", () => {
renderSidebar(true);
expect(screen.queryByText("Live")).toBeNull();
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
// The live/version line lives in the status bar and the update check in the
// connection details modal; language and theme live in Settings.
it("has no footer, language or theme controls", () => {
for (const collapsed of [false, true]) {
const { unmount } = renderSidebar(true, collapsed);
expect(screen.queryByText("Live")).toBeNull();
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
expect(screen.queryByRole("button", { name: "English" })).toBeNull();
expect(screen.queryByRole("button", { name: "Dark" })).toBeNull();
expect(screen.queryByText("Up to date")).toBeNull();
unmount();
}
});
it("offers the connection details from the collapsed rail", () => {
renderSidebar(false, true);
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
it("collapses and expands from a small button in the brand's corner", async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
const { unmount } = renderSidebar(true, false, { onToggle });
await user.click(screen.getByRole("button", { name: "Collapse sidebar" }));
unmount();
renderSidebar(true, true, { onToggle });
await user.click(screen.getByRole("button", { name: "Expand sidebar" }));
expect(onToggle).toHaveBeenCalledTimes(2);
});
it("renders the workspace list when expanded and its icon in the rail", () => {
@@ -81,39 +97,7 @@ describe("Sidebar", () => {
expect(hrefs).not.toContain("/activity");
});
it("should render both language options in expanded mode", () => {
renderSidebar(true);
expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument();
// Chinese and Korean were dropped; offering them would render raw keys.
expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull();
expect(screen.queryByRole("button", { name: "Korean" })).toBeNull();
});
it("should switch to Vietnamese when Vietnamese option is clicked", async () => {
const user = userEvent.setup();
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Vietnamese" }));
await waitFor(() => {
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
});
});
it("should cycle language in collapsed mode", async () => {
const user = userEvent.setup();
renderSidebar(true, true);
expect(screen.getByText("EN")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" }));
await waitFor(() => {
expect(screen.getByText("VI")).toBeInTheDocument();
});
});
describe("update status line", () => {
describe("update status line, in the connection details modal", () => {
const plugin = {
git_repo: false,
install_kind: "plugin",
@@ -124,32 +108,36 @@ describe("Sidebar", () => {
act(() => eventBus.publish({ type: "update_status", data } as never));
it("does not claim up to date when a plugin install could not read the release", () => {
renderSidebar(true);
renderSidebar(true, false, { connectionOpen: true });
publish({ ...plugin, update_available: false, release_check: "unreachable" });
expect(screen.queryByText("Up to date")).toBeNull();
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
expect(screen.getByTestId("update-status")).toHaveTextContent("Could not check for updates");
});
it("names a build installed beside the running one", () => {
renderSidebar(true);
renderSidebar(true, false, { connectionOpen: true });
publish({
...plugin,
update_available: true,
release_check: "unreachable",
installed_not_running: { root: "/x", version: "1.8.12" },
});
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
expect(screen.getByTestId("update-status")).toHaveTextContent(
"1.8.12 installed, not running"
);
});
it("still says up to date when the release was read and matches", () => {
renderSidebar(true);
renderSidebar(true, false, { connectionOpen: true });
publish({
...plugin,
update_available: false,
release_check: "ok",
latest_release: "v1.8.10",
});
expect(screen.getByText("Up to date")).toBeInTheDocument();
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByTestId("update-status")).toHaveTextContent("Up to date");
expect(within(dialog).getByRole("button", { name: "Check for updates" })).toBeEnabled();
});
});
});
@@ -1,65 +0,0 @@
/**
* @file Sidebar.theme.test.tsx
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
}
describe("Sidebar theme controls", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
renderSidebar();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking System sets the mode and presses only System", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "System" }));
expect(localStorage.getItem("theme")).toBe("system");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking Light applies light mode to <html>", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "Light" }));
expect(localStorage.getItem("theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme", "light");
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
});
});
@@ -2,7 +2,9 @@
* @file WorkspaceTree.test.tsx
* @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, selection
* a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive,
* selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, and the status dot.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -57,6 +59,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
})),
patch: vi.fn(async () => ({ session: {} })),
},
run: {
list: vi.fn(async () => ({
@@ -105,6 +108,12 @@ const rowIds = () => screen.getAllByTestId(/^workspace-row-\d+$/).map((el) => el
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
total: 2,
limit: 20,
offset: 0,
}));
lanes = [
lane(1, "/r", { title: "Repo" }),
lane(2, "/r/sub", { title: "Sub" }),
@@ -128,12 +137,50 @@ describe("WorkspaceTree", () => {
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).getByText("Live one")).toBeInTheDocument();
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/r", tool: "claude", limit: 20 });
// Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 20 });
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true,
});
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.dblClick(await screen.findByTestId("workspace-session-s-old"));
const input = screen.getByTestId("workspace-session-rename-s-old");
await user.clear(input);
await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("dots a session waiting for the user amber and a failed one red", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{
...session("s-wait", "Waiting"),
status: "active",
awaiting_input_since: "2026-09-30T10:00:00Z",
} as Session,
{ ...session("s-bad", "Broke"), status: "error" } as Session,
],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
expect(await screen.findByTestId("workspace-session-dot-s-wait")).toHaveClass(
"bg-status-warning"
);
expect(screen.getByTestId("workspace-session-dot-s-bad")).toHaveClass("bg-status-danger");
});
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => {
const user = userEvent.setup();
renderTree();
@@ -142,6 +189,11 @@ describe("WorkspaceTree", () => {
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
// Ctrl+click asks for a second pane.
fireEvent.click(live, { ctrlKey: true });
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
);
await user.click(screen.getByTestId("workspace-session-s-old"));
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
expect(api.lanes.action).not.toHaveBeenCalled();
@@ -225,7 +277,7 @@ describe("WorkspaceTree", () => {
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"
(params as { lane?: number }).lane === 2
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
: [session("s-live", "Live one"), session("s-old", "Old one")],
total: 2,