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
+21 -4
View File
@@ -21,6 +21,10 @@
*
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
*
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
* `agenthub-lane-<laneId>-<slot>`.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -35,7 +39,9 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
const MAX_PANE_SLOT = 3;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
// it asks only for what its classifier reads as risky. `default` is kept for
@@ -55,8 +61,13 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
function runIdForLane(laneId) {
return `agenthub-lane-${laneId}`;
function runIdForLane(laneId, slot = 0) {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
function slotFromRunId(id) {
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
return m ? Number(m[2] || 0) : null;
}
function laneIdFromRunId(id) {
@@ -149,6 +160,7 @@ function buildArgv(args) {
function spawnRun(args) {
const {
laneId,
slot = 0,
cwd,
tool = "claude",
model,
@@ -164,6 +176,9 @@ function spawnRun(args) {
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
}
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
}
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
@@ -215,7 +230,7 @@ function spawnRun(args) {
);
}
const id = runIdForLane(laneId);
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
const argv = buildArgv({
tool,
@@ -323,6 +338,7 @@ function publicRun(id) {
return {
id,
laneId,
slot: slotFromRunId(id),
status: live ? "running" : "gone",
tool: row?.tool || "claude",
cwd: row?.cwd || null,
@@ -353,5 +369,6 @@ module.exports = {
listRuns,
laneIdFromRunId,
runIdForLane,
MAX_PANE_SLOT,
SHELL_COMMANDS,
};