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
+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);