feat(lanes): name the files behind a lane's uncommitted counts
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.
This commit is contained in:
@@ -539,7 +539,20 @@ export default function LaneCard({
|
|||||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||||
{(git.dirty > 0 || git.untracked > 0) && (
|
{(git.dirty > 0 || git.untracked > 0) && (
|
||||||
<span className="shrink-0 text-status-warning/80">
|
<span
|
||||||
|
className="shrink-0 cursor-help text-status-warning/80"
|
||||||
|
/* Native title: the counts' own file list, capped server-side.
|
||||||
|
A hover card would need a portal to escape this truncating
|
||||||
|
row — the browser's tooltip already escapes it for free. */
|
||||||
|
title={
|
||||||
|
git.files?.length
|
||||||
|
? git.files.join("\n") +
|
||||||
|
(git.dirty + git.untracked > git.files.length
|
||||||
|
? `\n… +${git.dirty + git.untracked - git.files.length}`
|
||||||
|
: "")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ describe("LaneCard git block", () => {
|
|||||||
subject: "free-text rule mode in the form",
|
subject: "free-text rule mode in the form",
|
||||||
dirty: 2,
|
dirty: 2,
|
||||||
untracked: 1,
|
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 () => {
|
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("9b3e74a");
|
||||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||||
expect(block.textContent).toContain("2");
|
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.
|
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2592,6 +2592,8 @@ export type LaneGitFacts =
|
|||||||
dirty: number;
|
dirty: number;
|
||||||
/** Files git has never seen. */
|
/** Files git has never seen. */
|
||||||
untracked: number;
|
untracked: number;
|
||||||
|
/** Raw `git status --porcelain` lines, capped; the counts stay exact. */
|
||||||
|
files: string[];
|
||||||
}
|
}
|
||||||
| { available: false };
|
| { available: false };
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -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:
|
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.
|
- **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.
|
- **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.
|
- **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",
|
"head": "9b3e74a",
|
||||||
"subject": "free-text rule mode in the form",
|
"subject": "free-text rule mode in the form",
|
||||||
"dirty": 2,
|
"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.
|
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
|
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||||
prettier invention.
|
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`.
|
- An unknown lane id is a `404`.
|
||||||
|
|
||||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||||
|
|||||||
+1
-1
@@ -10700,7 +10700,7 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'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':
|
'404':
|
||||||
description: Lane not found.
|
description: Lane not found.
|
||||||
/api/lanes/{id}/runtime:
|
/api/lanes/{id}/runtime:
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ describe("worktree", () => {
|
|||||||
assert.equal(s.dirty, 1);
|
assert.equal(s.dirty, 1);
|
||||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
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
|
// 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.
|
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||||
|
|||||||
+11
-3
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
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 }.
|
* 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.
|
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||||
* Lines starting with ?? are untracked; others are dirty.
|
* 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) {
|
async function statusCounts(dir) {
|
||||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||||
let dirty = 0;
|
let dirty = 0;
|
||||||
let untracked = 0;
|
let untracked = 0;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
for (const line of result.stdout.split("\n")) {
|
for (const line of result.stdout.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@@ -562,13 +569,14 @@ async function statusCounts(dir) {
|
|||||||
} else {
|
} else {
|
||||||
dirty++;
|
dirty++;
|
||||||
}
|
}
|
||||||
|
if (files.length < STATUS_FILE_CAP) files.push(line.trimEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get short commit hash
|
// Get short commit hash
|
||||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||||
const head = headResult.stdout.trim();
|
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.
|
* caller shows what git says rather than inventing a nicer word for it.
|
||||||
*/
|
*/
|
||||||
async function gitFacts(dir) {
|
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 branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const paths = {
|
|||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description:
|
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." },
|
404: { description: "Lane not found." },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user