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
+12 -12
View File
@@ -135,21 +135,21 @@ describe("lanes on catalog tools", () => {
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "gemini");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
assert.equal(r.body.error.code, "EBADTOOL");
await request("PUT", "/api/ai-tools/aider", { enabled: false });
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
await request("PUT", "/api/ai-tools/omp", { enabled: false });
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "omp" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "omp" });
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
await request("PUT", "/api/ai-tools/aider", { enabled: true });
await request("PUT", "/api/ai-tools/omp", { enabled: true });
});
});
@@ -190,23 +190,23 @@ describe("install / update / icons", () => {
it("starts an install job and reports it", async () => {
jobs.__reset();
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
const r = await request("POST", "/api/ai-tools/qwen-code/install");
const r = await request("POST", "/api/ai-tools/copilot/install");
assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "qwen-code");
assert.equal(r.body.job.tool, "copilot");
const done = await settle();
assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"),
"install",
"-g",
"@qwen-code/qwen-code@latest",
"@github/copilot@latest",
]);
});
it("maps refusals to statuses", async () => {
jobs.__reset();
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
const noInstall = await request("POST", "/api/ai-tools/aider/install");
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
assert.equal(noInstall.status, 400);
assert.equal(noInstall.body.error.code, "ENOINSTALL");
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
Origin: "https://evil.test",
});
assert.equal(cross.status, 403);
+41
View File
@@ -292,6 +292,47 @@ describe("Sessions API", () => {
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
});
it("lists only the sessions a workspace owns, not the rest of its folder", async () => {
const laneId = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/tmp/own-ws', 'claude')")
.run().lastInsertRowid;
const add = (id, cwd, lane) =>
db
.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
)
.run(id, id, cwd, lane);
add("own-1", "/tmp/own-ws", laneId);
add("own-sub", "/tmp/own-ws/pkg", laneId); // cd'd into a subfolder — still its own
add("stray-1", "/tmp/own-ws", null); // a terminal session in the same folder
const own = await fetch(`/api/sessions?lane=${laneId}`);
assert.deepEqual(own.body.sessions.map((s) => s.id).sort(), ["own-1", "own-sub"]);
assert.equal((await fetch("/api/sessions?lane=abc")).status, 400);
});
it("archives a session out of a workspace's list and back, deleting nothing", async () => {
const laneId = stmts.getSession.get("own-1").lane_id;
let res = await patch("/api/sessions/own-1", { archived: true });
assert.equal(res.status, 200);
assert.ok(res.body.session.archived_at);
const live = await fetch(`/api/sessions?lane=${laneId}&archived=0`);
assert.deepEqual(
live.body.sessions.map((s) => s.id),
["own-sub"]
);
const gone = await fetch(`/api/sessions?lane=${laneId}&archived=1`);
assert.deepEqual(
gone.body.sessions.map((s) => s.id),
["own-1"]
);
// Without the parameter every session is listed, as before.
assert.equal((await fetch(`/api/sessions?lane=${laneId}`)).body.sessions.length, 2);
res = await patch("/api/sessions/own-1", { archived: "yes" });
assert.equal(res.status, 400);
res = await patch("/api/sessions/own-1", { archived: false });
assert.equal(res.body.session.archived_at, null);
});
it("should paginate sessions", async () => {
const res = await fetch("/api/sessions?limit=1&offset=0");
assert.equal(res.body.sessions.length, 1);
+52
View File
@@ -0,0 +1,52 @@
/**
* @file Tests for the persisted run history (`server/lib/dashboard-runs.js`).
* A run's id is its tmux pane name, which every Start in that pane reuses, so
* a new run must ARCHIVE the previous row under its own id instead of
* overwriting it; and a row is flagged dead only when its tmux session is
* really gone — panes outlive a server restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "dashboard-runs-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
let runs;
before(() => {
runs = require("../lib/dashboard-runs");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, extra = {}) =>
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra });
describe("dashboard-runs", () => {
it("keeps the previous run of a pane as history when the pane starts again", () => {
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-01T10:00:00Z"), sessionId: "s-old" });
start("agenthub-lane-4", { startedAt: Date.parse("2026-10-02T10:00:00Z"), sessionId: "s-new" });
const current = runs.getRun("agenthub-lane-4");
assert.equal(current.session_id, "s-new");
const history = runs.listRuns({ laneId: 4 });
assert.equal(history.length, 2);
const old = history.find((r) => r.session_id === "s-old");
assert.notEqual(old.id, "agenthub-lane-4");
assert.ok(old.id.startsWith("agenthub-lane-4@"), old.id);
// A pane that is being replaced is not running any more.
assert.equal(old.status, "abandoned");
assert.ok(old.ended_at);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
const n = runs.reconcileOrphans((id) => id !== "agenthub-lane-6");
assert.equal(n, 1);
assert.equal(runs.getRun("agenthub-lane-5").status, "running");
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
});
});
@@ -0,0 +1,10 @@
{
"id": "command-code",
"label": "Command Code",
"detect": ["command-code"],
"args": ["--trust"],
"prompt": {
"mode": "argv"
},
"bypass": ["--yolo"]
}
@@ -0,0 +1,12 @@
{
"id": "goose",
"label": "Goose",
"detect": ["goose"],
"prompt": {
"mode": "after-start",
"delayMs": 3000
},
"bypassEnv": {
"GOOSE_MODE": "auto"
}
}
@@ -0,0 +1,10 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"prompt": {
"mode": "argv",
"separator": "--"
},
"bypass": ["--permission-mode", "bypassPermissions"]
}
+19
View File
@@ -426,6 +426,25 @@ describe("lane start input", () => {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
it("answers 400 for a fork with nothing to fork, or on a tool that cannot fork", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-fork-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const codexId = (await request("POST", "/api/lanes", { cwd, tool: "codex" })).body.lane.id;
try {
let r = await request("POST", `/api/lanes/${id}/start`, { forkSession: true });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSESSION");
r = await request("POST", `/api/lanes/${codexId}/start`, {
forkSession: true,
resumeSessionId: "abc12345",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
+41 -2
View File
@@ -196,6 +196,40 @@ describe("pty-run", () => {
assert.ok(newSessionArgv.includes("abc12345"));
});
it("spawnRun forks a resumed Claude session into a new id, and refuses a fork elsewhere", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 31, cwd: "/tmp/repo", resumeSessionId: "abc12345", forkSession: true });
const at = newSessionArgv.indexOf("--resume");
assert.deepEqual(newSessionArgv.slice(at, at + 3), ["--resume", "abc12345", "--fork-session"]);
// The pane holds the FORK, whose id only its first hook reports.
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-31").session_id, null);
assert.throws(
() => pty.spawnRun({ laneId: 32, cwd: "/tmp/repo", forkSession: true }),
(e) => e.code === "EBADSESSION"
);
assert.throws(
() =>
pty.spawnRun({
laneId: 33,
cwd: "/tmp/repo",
tool: "codex",
resumeSessionId: "abc12345",
forkSession: true,
}),
(e) => e.code === "EUNSUPPORTED"
);
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
@@ -427,11 +461,16 @@ describe("pty-run", () => {
}
savedPath = process.env.PATH;
process.env.PATH = `${BIN}:/usr/bin:/bin`;
aiTools.refresh();
// The built-in catalog plus three test-only tools for the prompt modes
// and bypassEnv no built-in tool uses any more (custom files may).
aiTools.__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
path.join(__dirname, "fixtures", "ai-tools"),
]);
});
after(() => {
process.env.PATH = savedPath;
aiTools.refresh();
aiTools.__setCatalogDirs(null);
});
const at = (name) => path.join(BIN, name);
const envOf = (call, key) => {
+59 -1
View File
@@ -3,7 +3,9 @@
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
* name, or names no run, changes nothing.
* name, or names no run, changes nothing. A session a lane's pane holds is
* stamped with that lane (`sessions.lane_id`) — the workspace owns it — and a
* nested CLI's session, or one another lane already owns, is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,6 +24,7 @@ process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const { db } = require("../db");
const dashboardRuns = require("../lib/dashboard-runs");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
@@ -140,4 +143,59 @@ describe("run ↔ session binding", () => {
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("stamps the session a lane's pane holds with that lane, once", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-8",
laneId: 8,
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const laneOf = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id)?.lane_id;
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-aaaa-0001", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-8",
});
assert.equal(laneOf("own-aaaa-0001"), 8);
// A nested `claude -p` in that pane is not the pane's session.
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-nest-0002", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-8",
});
assert.equal(laneOf("own-nest-0002"), null);
// A session another lane owns keeps its owner when resumed elsewhere.
db.prepare("UPDATE sessions SET lane_id = 3 WHERE id = ?").run("own-nest-0002");
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-nest-0002", cwd: ROOT, source: "resume" },
run_id: "agenthub-lane-8",
});
assert.equal(dashboardRuns.getRun("agenthub-lane-8").session_id, "own-nest-0002");
assert.equal(laneOf("own-nest-0002"), 3);
});
it("stamps a resumed session the run was started with", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-9",
laneId: 9,
sessionId: "own-resu-0003",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
await postHook({
hook_type: "SessionStart",
data: { session_id: "own-resu-0003", cwd: ROOT, source: "resume" },
run_id: "agenthub-lane-9",
});
assert.equal(
db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get("own-resu-0003").lane_id,
9
);
});
});