From af2ad63a189d72c5b679f30160eb5fb158004f0e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 17 Sep 2026 13:19:07 +0700 Subject: [PATCH] feat(lanes): name the files behind a lane's uncommitted counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/lanes/:id/git now returns `files`, the raw `git status --porcelain` lines the dirty/untracked counts were already parsed from, capped at 20 so a lane mid-`npm install` cannot ship thousands of paths through a polled endpoint. The lane card hovers them as the tooltip on its "N modified · N untracked" text, appending "… +N" when the cap bit. No new subprocess and no new endpoint — statusCounts was counting those lines and throwing them away. --- client/src/components/lanes/LaneCard.tsx | 15 ++++++++++++++- .../components/lanes/__tests__/LaneCard.test.tsx | 5 +++++ client/src/lib/types.ts | 2 ++ docs/LANES.md | 12 ++++++++++-- openapi.yaml | 2 +- server/__tests__/worktree.test.js | 2 ++ server/lib/worktree.js | 14 +++++++++++--- server/openapi-extra/lanes.js | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 81acac7c..59d4da07 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -539,7 +539,20 @@ export default function LaneCard({ {git.head} {git.subject} {(git.dirty > 0 || git.untracked > 0) && ( - + git.files.length + ? `\n… +${git.dirty + git.untracked - git.files.length}` + : "") + : undefined + } + > {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} )} diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 68b13aa7..76554181 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -207,6 +207,7 @@ describe("LaneCard git block", () => { subject: "free-text rule mode in the form", dirty: 2, untracked: 1, + files: [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"], }; it("renders the live branch, head, subject and uncommitted counts", async () => { @@ -218,6 +219,10 @@ describe("LaneCard git block", () => { expect(block.textContent).toContain("9b3e74a"); expect(block.textContent).toContain("free-text rule mode in the form"); expect(block.textContent).toContain("2"); + // Hovering the counts names the files behind them. + expect(screen.getByText(/modified/).title).toBe( + " M server/lib/alerts.js\n M client/src/lib/api.ts\n?? scratch.txt" + ); // The live branch replaces the lane's recorded one rather than doubling it. expect(screen.queryByText(/stale\/recorded/)).toBeNull(); }); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index f8e651f9..20f3b3cb 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2592,6 +2592,8 @@ export type LaneGitFacts = dirty: number; /** Files git has never seen. */ untracked: number; + /** Raw `git status --porcelain` lines, capped; the counts stay exact. */ + files: string[]; } | { available: false }; diff --git a/docs/LANES.md b/docs/LANES.md index 0e2f6983..f19676c4 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -370,7 +370,9 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom: -- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human. +- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers. +- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. +- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name, time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which. - **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. - **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads. @@ -442,7 +444,8 @@ curl http://localhost:4820/api/lanes/5/git "head": "9b3e74a", "subject": "free-text rule mode in the form", "dirty": 2, - "untracked": 1 + "untracked": 1, + "files": [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"] } ``` @@ -451,6 +454,11 @@ curl http://localhost:4820/api/lanes/5/git is a normal state, not a fault, and the card simply renders without a git row. - A detached checkout reports the literal `HEAD` that git returns, rather than a prettier invention. +- `files` holds the raw `git status --porcelain` lines the two counts came from, + **capped at 20** — the card hovers them as a tooltip on the + `N modified · N untracked` text, and a lane mid-`npm install` would otherwise + ship thousands of paths through a polled endpoint. The counts are never + capped, so the tooltip appends `… +N` when it is showing less than all of it. - An unknown lane id is a `404`. This is **not** folded into `GET /api/lanes` on purpose: it shells out to git diff --git a/openapi.yaml b/openapi.yaml index 9390ec6f..a58024b8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10700,7 +10700,7 @@ paths: type: integer responses: '200': - description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone. + description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped. '404': description: Lane not found. /api/lanes/{id}/runtime: diff --git a/server/__tests__/worktree.test.js b/server/__tests__/worktree.test.js index 1b497e09..aab4a32e 100644 --- a/server/__tests__/worktree.test.js +++ b/server/__tests__/worktree.test.js @@ -100,6 +100,8 @@ describe("worktree", () => { assert.equal(s.dirty, 1); assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count assert.match(s.head, /^[0-9a-f]{7,40}$/); + // The same lines the counts came from, so a caller can name the files. + assert.deepEqual(s.files.sort(), [" M README.md", "?? scratch.txt"]); // The fixture has no remotes. Given the lane's base branch, unpushedCount // measures base..HEAD — this worktree has committed nothing of its own, so 0. assert.equal(await wt.unpushedCount(dir, "main"), 0); diff --git a/server/lib/worktree.js b/server/lib/worktree.js index 79d71f9d..2969cec9 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile); const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes"); const PROTECTED_BRANCHES = new Set(["main", "master"]); +/** Most status lines `statusCounts` reports by name; the counts stay exact. */ +const STATUS_FILE_CAP = 20; /** * Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }. @@ -549,11 +551,16 @@ async function removeWorktree(lane) { /** * Parse git status --porcelain to count dirty, untracked, and get HEAD commit. * Lines starting with ?? are untracked; others are dirty. + * + * `files` carries the same lines back verbatim so a caller can show WHICH + * files the counts mean, capped because this rides a polled endpoint and a + * lane mid-`npm install` can report thousands. */ async function statusCounts(dir) { const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]); let dirty = 0; let untracked = 0; + const files = []; for (const line of result.stdout.split("\n")) { if (!line.trim()) continue; @@ -562,13 +569,14 @@ async function statusCounts(dir) { } else { dirty++; } + if (files.length < STATUS_FILE_CAP) files.push(line.trimEnd()); } // Get short commit hash const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]); const head = headResult.stdout.trim(); - return { dirty, untracked, head }; + return { dirty, untracked, head, files }; } /** @@ -581,10 +589,10 @@ async function statusCounts(dir) { * caller shows what git says rather than inventing a nicer word for it. */ async function gitFacts(dir) { - const { dirty, untracked, head } = await statusCounts(dir); + const { dirty, untracked, head, files } = await statusCounts(dir); const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim(); - return { branch, head, subject, dirty, untracked }; + return { branch, head, subject, dirty, untracked, files }; } /** diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index f314f374..f122282b 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -274,7 +274,7 @@ const paths = { responses: { 200: { description: - "available:true with {branch, head, subject, dirty, untracked}, or available:false alone.", + "available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped.", }, 404: { description: "Lane not found." }, },