feat(lanes): split panes on one lane, in-place file editing, hunk review bar

- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
2026-09-24 15:49:39 +07:00
parent 67210bcd61
commit d397d3a1f6
29 changed files with 1289 additions and 254 deletions
+27
View File
@@ -458,6 +458,33 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
write(LANE, "src/main.ts", "m\n");
write(LANE, ".gitignore", "*.log\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "a.txt", "modified\n");
write(LANE, "new.txt", "fresh\n");
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
write(LANE, "noise.log", "ignored\n");
const root = await listTree(LANE, {});
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
assert.equal(by["a.txt"].status, " M");
assert.equal(by["new.txt"].status, "??");
assert.equal(by["keep.txt"].status, undefined);
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
assert.equal(by.gen.changed, true);
assert.equal(by.src.changed, undefined);
// Inside a folder git reports only as "gen/": still untracked, still marked.
const deep = await listTree(LANE, { subdir: "gen/deep" });
assert.equal(deep.entries[0].status, "??");
const all = await listTreeAll(LANE);
assert.equal(all.levels["gen"][0].changed, true);
assert.deepEqual(all.levels[""], root.entries);
});
it("reads every level of the tree in one pass, grouped like listTree", async () => {
write(LANE, "src/api/x.ts", "x\n");
write(LANE, "src/main.ts", "m\n");
+41
View File
@@ -959,6 +959,47 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
});
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
assert.equal(typeof opened.body.sha, "string");
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "one\nTWO\nthree",
baseSha: opened.body.sha,
});
assert.equal(saved.status, 200, JSON.stringify(saved.body));
// The final newline the file had survives an editor that drops it.
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
assert.notEqual(saved.body.sha, opened.body.sha);
// Saving over the OLD stamp is a conflict, never a silent overwrite.
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "a.txt",
content: "clobbered",
baseSha: opened.body.sha,
});
assert.equal(stale.status, 409);
assert.equal(stale.body.error.code, "ESTALE");
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: "../../etc/passwd",
content: "x",
baseSha: "0".repeat(40),
});
assert.equal(escape.status, 400);
assert.equal(escape.body.error.code, "EBADPATH");
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
path: ".claude/settings.local.json",
content: "{}",
baseSha: "0".repeat(40),
});
assert.equal(secret.status, 403);
assert.equal(secret.body.error.code, "ESECRET");
});
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
+2
View File
@@ -55,6 +55,8 @@ describe("pty-attach", () => {
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
});
it("turns tmux mouse mode on for the session it attaches to", () => {
+20
View File
@@ -58,6 +58,26 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-42-2");
assert.equal(handle.laneId, 42);
assert.equal(handle.slot, 2);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
assert.throws(
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
(err) => err.code === "EBADSLOT"
);
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request