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();
});
});
});