feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)

- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
2026-10-06 12:36:06 +07:00
parent 6c605849d8
commit f0500dc592
41 changed files with 1201 additions and 249 deletions
+15
View File
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-routers — remove", () => {
+91
View File
@@ -0,0 +1,91 @@
/**
* @file Tests for server/lib/commit-message.js against a real git repo and a
* fake `claude` script: only staged, in-lane, non-secret changes reach the
* model; its argv keeps tools, hooks and persistence off; its answer is
* cleaned; and every failure is a coded error.
* @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 { execFileSync } = require("node:child_process");
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
const REPO = path.join(ROOT, "repo");
const SUB = path.join(REPO, "sub");
const ARGS = path.join(ROOT, "args.txt");
const STDIN = path.join(ROOT, "stdin.txt");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_INDEX_FILE;
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
};
const write = (file, body) => {
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
fs.writeFileSync(path.join(REPO, file), body);
};
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
function fakeClaude(reply, code = 0) {
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(
bin,
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
{ mode: 0o755 }
);
return bin;
}
before(() => {
fs.mkdirSync(REPO);
g("init", "-q", "-b", "main");
write("a.txt", "one\n");
g("add", "-A");
g("commit", "-q", "-m", "feat: seed");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("commit-message", () => {
it("refuses with ENOSTAGED when nothing is staged", async () => {
write("a.txt", "one\nunstaged\n");
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
});
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
write("b.txt", "staged line\n");
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
g("add", "-f", "b.txt", ".claude/settings.local.json");
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
assert.equal(msg, "feat: add b");
const stdin = fs.readFileSync(STDIN, "utf8");
assert.match(stdin, /feat: seed/);
assert.match(stdin, /staged line/);
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
assert.ok(argv.includes(flag), flag);
}
assert.equal(argv[argv.indexOf("--tools") + 1], "");
});
it("scopes a subdirectory lane to its own staged files", async () => {
write("sub/c.txt", "inside\n");
g("add", "sub/c.txt");
const input = await buildInput(SUB);
assert.match(input, /inside/);
assert.doesNotMatch(input, /staged line/);
});
it("reports a failing or silent CLI as EAI", async () => {
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
code: "EAI",
});
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
});
});
+9
View File
@@ -52,6 +52,15 @@ describe("dashboard-runs", () => {
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
});
it("forgets a closed Terminal tab and nothing of another tool", () => {
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
assert.ok(runs.getRun("agenthub-lane-9"));
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
+2 -1
View File
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
+5
View File
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
// Its own route, not an action: never reaches claude with nothing staged.
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
assert.equal(suggest.status, 400);
assert.equal(suggest.body.error.code, "ENOSTAGED");
});
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
+45 -15
View File
@@ -75,11 +75,9 @@ describe("pty-run", () => {
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(
path.join(bin, "tmux"),
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
{ mode: 0o755 }
);
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
mode: 0o755,
});
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
@@ -492,11 +490,43 @@ describe("pty-run", () => {
return calls.find((c) => c[0] === "new-session");
}
/** The pane's own command line — everything tmux passes after the `--`. */
/** The CLI's own command line: what tmux passes after the `--`, less the
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
assert.match(argv[2], /session_attached.*exec "\$@"$/);
return argv.slice(4);
}
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
);
const argv = paneArgv(call);
assert.ok(argv.includes("a'; rm -rf / #"));
const { execFileSync } = require("node:child_process");
const out = execFileSync(
"sh",
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
"printf",
"%s|",
"x y",
"$HOME",
],
{
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
encoding: "utf8",
timeout: 5000,
}
);
assert.equal(out, "x y|$HOME|");
});
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
@@ -583,15 +613,11 @@ describe("pty-run", () => {
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
// Forcing it inline duplicated the transcript on every pane narrowing.
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
tmux.__reset();
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => {
@@ -719,7 +745,11 @@ describe("pty-run", () => {
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
live = true;
t.mock.timers.tick(3000);
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
// waiting for a client to attach.
t.mock.timers.tick(4999);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
t.mock.timers.tick(1);
assert.deepEqual(
calls.find((c) => c[0] === "set-buffer"),
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
+27
View File
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
assert.equal("modelPicker" in written, false);
});
it("maps every distinct router model name with behavesAs (issue #56)", () => {
const { name } = routers.createRouter({
label: "9Router combo",
baseUrl: "http://localhost:20128/v1",
model: "agent",
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
behavesAs: "claude-opus-5-5",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
// A bare claude-* id is one Claude Code knows; mapping it would override
// its real handling.
assert.deepEqual(written.modelPicker, {
options: [
{ model: "agent", behavesAs: "claude-opus-5-5" },
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
],
});
});
});
describe("routerForSpawn — Codex", () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
"-t",
"=agenthub-lane-1:",
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
});