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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user