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
@@ -502,3 +502,34 @@ describe("LaneDiffView — hunk review bar navigation", () => {
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
describe("LaneDiffView — line comments", () => {
it("writes a comment on a new line and hands over `<path>:<line> — <text>`", () => {
const sent: string[] = [];
let delivered = false;
render(
<LaneDiffView
file={FILE}
layout="unified"
onComment={(text) => {
sent.push(text);
return delivered;
}}
/>
);
fireEvent.click(screen.getByTestId("diff-comment-line-1"));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } });
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]);
// No pane took it: the box stays, with the reason.
expect(screen.getByRole("alert")).toBeInTheDocument();
delivered = true;
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
});
it("offers no comment button without onComment", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
});
});
@@ -334,7 +334,7 @@ describe("git login (issue #45)", () => {
const form = await screen.findByTestId("lane-git-login");
expect(form).toHaveTextContent("git.smartgift.io.vn");
expect(screen.getByLabelText("Username")).toHaveValue("vi");
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
expect(screen.getByRole("checkbox", { name: /Remember for everyone/ })).toBeChecked();
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
@@ -1,10 +1,10 @@
/**
* @file SessionResumeButton.test.tsx
* @description The transcript page's explicit Resume: it shows only for a
* @description The transcript page's explicit Resume and Fork: it shows only for a
* Claude session inside a workspace, a live session offers its pane instead,
* and a resume asks first (it re-reads the whole transcript), then starts in
* the first free pane slot and opens that pane — or refuses when all four are
* busy.
* busy; a Fork asks too and starts a new session id beside a live original.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -114,6 +114,28 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
});
it("forks into a free pane after asking, even while the original has a live pane", async () => {
runs = [
{
id: "agenthub-lane-3",
laneId: 3,
slot: 0,
status: "running",
sessionId: "s-1",
} as RunHandle,
];
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Fork" }));
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
await user.click(screen.getByRole("button", { name: "Fork session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-1",
forkSession: true,
});
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
@@ -62,7 +62,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
routerList: vi.fn(async () => ({ routers: [] })),
},
run: { list: vi.fn(async () => ({ items: runs })) },
sessions: { list: vi.fn(async () => ({ sessions })) },
sessions: {
list: vi.fn(async () => ({ sessions })),
patch: vi.fn(async () => ({ session: {} })),
},
projects: {
list: vi.fn(async () => ({
projects: [
@@ -134,6 +137,12 @@ function renderHome(l = lane()) {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions,
total: sessions.length,
limit: 30,
offset: 0,
}));
view = {
instructions: "",
supported: true,
@@ -196,11 +205,28 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("folds the archived sessions with Unarchive", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
sessions: (params as { archived?: boolean }).archived ? [sessions[1]!] : [],
total: (params as { archived?: boolean }).archived ? 1 : 0,
limit: 30,
offset: 0,
}));
const user = userEvent.setup();
renderHome();
const fold = await screen.findByTestId("workspace-archived-1");
await user.click(within(fold).getByRole("button", { name: /1 archived session/ }));
await user.click(within(fold).getByTestId("workspace-archived-1-action-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
});
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
// Recents are the workspace's OWN sessions, not the folder's.
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 30 });
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
await user.click(screen.getByTestId("workspace-recent-s-old"));
@@ -1,90 +0,0 @@
/**
* @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();
});
});