feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
+174
-1
@@ -381,7 +381,11 @@ What the lane's directory looks like to git right now:
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
],
|
||||
"hasRemote": true,
|
||||
"upstream": "origin/feat/rename-metric",
|
||||
"ahead": 2,
|
||||
"behind": 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -393,10 +397,179 @@ What the lane's directory looks like to git right now:
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||||
unstaged work together — and are `null` when git has no delta to report: an
|
||||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||||
- `hasRemote`, `upstream`, `ahead` and `behind` describe the branch's standing
|
||||
against its remote-tracking ref. A branch that has never been pushed reports
|
||||
`upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
|
||||
"in sync" about a comparison that was never possible.
|
||||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||||
and the lane list is polled and re-broadcast on every hook. See
|
||||
`docs/LANES.md` for the full treatment.
|
||||
|
||||
#### Read a lane's code changes
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
|
||||
```
|
||||
|
||||
One route, three depths — the shapes the Workspace's Changes panel asks for:
|
||||
|
||||
| Query | Shape | Used by |
|
||||
| --- | --- | --- |
|
||||
| *(none)* | every changed file with its counts, no patch text | overview |
|
||||
| `full=1` | the same list, each file carrying its `hunks` | review |
|
||||
| `path=<file>` | that one file, with its `hunks` | single-file view |
|
||||
| `context=N` | the same, with N unchanged lines around each hunk | the viewer's expand button |
|
||||
|
||||
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
|
||||
untracked files included, `branch` is `<base>...HEAD` for the lane's
|
||||
`base_branch`, and `commit` shows the single commit named by `sha` (an
|
||||
unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
|
||||
`context` defaults to **3** and is clamped to **5000** — at the top of that
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
all" means in the panel.
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"range": "work",
|
||||
"base": "development",
|
||||
"baseRef": "origin/development",
|
||||
"sha": null,
|
||||
"context": 3,
|
||||
"truncated": false,
|
||||
"files": [
|
||||
{
|
||||
"path": "server/lib/git-ops.js",
|
||||
"status": " M",
|
||||
"untracked": false,
|
||||
"staged": false,
|
||||
"unstaged": true,
|
||||
"conflicted": false,
|
||||
"added": 12,
|
||||
"deleted": 3,
|
||||
"binary": false,
|
||||
"hunks": [
|
||||
{
|
||||
"header": "@@ -1,2 +1,3 @@",
|
||||
"context": "runGitAction",
|
||||
"oldStart": 1,
|
||||
"newStart": 1,
|
||||
"lines": [{ "type": "add", "oldLine": null, "newLine": 2, "text": " const x = 1;" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Each line is already resolved to both gutters (`oldLine`/`newLine`, one of
|
||||
them `null` on an added or removed line), so a client renders rows without
|
||||
re-parsing `@@` headers.
|
||||
- `staged`, `unstaged` and `conflicted` come from the porcelain XY columns and
|
||||
are reported for `range=work` only — they are what the panel's per-file
|
||||
stage/unstage buttons key off.
|
||||
- An **untracked** file has no git-side "before", so its patch is synthesized
|
||||
as one all-added hunk from the file itself. A binary file reports
|
||||
`binary: true` and `hunks: []`.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `400 ENOBASE` when `range=branch` on a lane with no base
|
||||
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
|
||||
|
||||
#### Read a lane's commits, a file, or the file tree
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
GET /api/lanes/:id/git/ls?q=<substring>
|
||||
GET /api/lanes/:id/git/tree?path=<dir>
|
||||
```
|
||||
|
||||
The reads behind the Changes panel's **History**, whole-file view, quick-open
|
||||
and file explorer. All four are read-only and unguarded, and all four answer
|
||||
`{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
|
||||
`rev=work` (default) is the file on disk — the one branch that reads the
|
||||
filesystem, and it is realpath-confined to the lane directory; anything else
|
||||
goes through `git show <rev>:<path>`. Binary content is reported, never
|
||||
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
|
||||
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
|
||||
reads `git ls-files`, so ignored files never appear and no skip-list has to
|
||||
be maintained; basename hits rank first.
|
||||
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
|
||||
— one directory level, tracked entries only, directories folded and listed
|
||||
first. `400 EBADPATH` for a path that escapes the lane.
|
||||
|
||||
#### Read a lane's own branches
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/branches
|
||||
```
|
||||
|
||||
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the lane's own repo, for the branch switcher. The repo
|
||||
comes from the lane row, never from the query (that is what `GET
|
||||
/api/lanes/branches` is for, which takes a candidate path for a lane that does
|
||||
not exist yet).
|
||||
|
||||
#### Act on a lane's working copy
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/git/:action
|
||||
```
|
||||
|
||||
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
|
||||
per lane under the lane lock.
|
||||
|
||||
| Action | Runs | Body |
|
||||
| --- | --- | --- |
|
||||
| `fetch` | `git fetch --all --prune` | — |
|
||||
| `pull` | `git pull --ff-only` | — |
|
||||
| `push` | `git push -u origin HEAD` | — |
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
```
|
||||
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no merge, rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
`discard` accept only paths `git status` is reporting as changed right now,
|
||||
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
|
||||
- `discard` is the one irreversible action here and it **requires**
|
||||
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
|
||||
- `branch-create` never switches — the usual reason to create one from a lane
|
||||
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`); a git failure is
|
||||
**500** with git's own `stderr` appended to `error.message` — a rejection or a
|
||||
credential failure reads better in git's words.
|
||||
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
|
||||
remote fails instead of holding the request open.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
```http
|
||||
|
||||
+189
-5
@@ -41,9 +41,53 @@ Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-eff
|
||||
The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
|
||||
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`).
|
||||
|
||||
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
In Worktree mode the branch question has three answers, because git's own rule
|
||||
decides which is available (issue #34):
|
||||
|
||||
- **New branch** — pick the base to fork from, and optionally a name. Leaving
|
||||
the name empty is the normal case: the route derives `${LANE_BRANCH_PREFIX}${slug}`
|
||||
exactly as the CLI does, and the field's placeholder previews that name as you
|
||||
type the title. The modal no longer demands one.
|
||||
- **Existing branch** — check a branch that already exists out into the new
|
||||
worktree, with no base and no new branch. Git allows one worktree per branch,
|
||||
so any branch another worktree already holds is listed but **disabled**, named
|
||||
with the directory holding it; picking it would have failed provisioning with
|
||||
`EBRANCHBUSY` after the lane row already existed.
|
||||
- **Current branch** — no branch at all: the lane simply *is* the repo, working
|
||||
on whatever is checked out (`main`). Small and personal projects usually want
|
||||
this and nothing more, so the choice sits beside the other two rather than
|
||||
being reachable only from the top-level mode toggle — it switches the modal
|
||||
to **Repo** mode, which is exactly that behaviour. A worktree lane could not
|
||||
offer it in any case: git will not check `main` out in a second worktree
|
||||
while the repo itself holds it.
|
||||
|
||||
The modal also accepts a **prefill** (repo + branch). That is what the lane git
|
||||
bar's **New lane here** opens — a sibling worktree of the same repo on the
|
||||
branch selected there, created through this same flow rather than a second
|
||||
provisioning path.
|
||||
|
||||
Above the branch fields, once the repo path resolves, the modal lists **that
|
||||
repo's existing worktrees** (branch + directory, first eight). `GET
|
||||
/api/lanes/branches?repo=` carries them alongside the branch list — one
|
||||
subprocess more on a form still being typed into, versus a second round trip
|
||||
for facts that expire together.
|
||||
|
||||
The repo field's autocomplete also offers a **Source repos** group: the
|
||||
`source_repo` of every lane already provisioned, newest first, above the
|
||||
session-history recents (`GET /api/run/cwds`, `kind: "repo"`). A worktree lane's
|
||||
recent cwd is the *worktree*, never the repo it was cut from, so the one path
|
||||
this field wants had to be retyped every time.
|
||||
|
||||
The modal has **no pipeline picker**: every lane it creates is born on
|
||||
`default` (the server's own default), and the template is changed afterwards
|
||||
from the lane's own detail header in Workspace, or at creation from the
|
||||
terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
|
||||
per-lane extra the form still asks for, and it sits in the form itself rather
|
||||
than behind a disclosure — a disclosure around a single field only hid it.
|
||||
|
||||
The **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
@@ -370,10 +414,10 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
||||
|
||||
- **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. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||||
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and a primary **Add lane** button. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), 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 / create-update readme, 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 every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, 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 every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **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. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
@@ -484,6 +528,124 @@ five times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||
every 30 seconds, and a failed fetch is silent.
|
||||
|
||||
The same response also carries the branch's standing against its remote:
|
||||
`hasRemote`, `upstream`, `ahead`, `behind`. A branch that has never been pushed
|
||||
reports `upstream: null` and **null** counts — not zeros, which would claim "in
|
||||
sync" about a comparison that was never possible. The card renders them as
|
||||
`↑2 ↓1` beside the git actions below.
|
||||
|
||||
### Acting on a lane's working copy: the git action row
|
||||
|
||||
Under the git line, a lane's card carries the git **writes** the dashboard
|
||||
offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
|
||||
|
||||
| Button | Runs | Refuses when |
|
||||
| --- | --- | --- |
|
||||
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Commit** | `git add -A` + `git commit -m …`, or **Commit staged** (the index as it stands) | nothing is uncommitted / nothing is staged |
|
||||
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||||
|
||||
These sit on the card rather than in the ⋯ menu deliberately: that menu holds
|
||||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||||
lane needs several times an hour. The boring half of git is the whole list on
|
||||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
deliberately does not switch: the usual reason to make a branch from a lane is
|
||||
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
|
||||
working agent is not. **New lane here** goes through the normal Add-lane flow
|
||||
with the fields prefilled — one provisioning path for every lane, not a second
|
||||
one hidden in the git bar.
|
||||
|
||||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||||
says more than any sentence the dashboard could write.
|
||||
|
||||
### The Changes panel
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps narrow → 50/50 → 70/30 → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
a setting — and one button beats a width button wedged beside a collapse button
|
||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||
width the next click LANDS on (even split → wider → closed), with the wording
|
||||
left to its tooltip. It
|
||||
reads the lane's git surface in five modes (**Files** sits first: it is the
|
||||
only one that answers "show me the code" rather than "show me the change"):
|
||||
|
||||
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
|
||||
(`/git/tree`), which expands folders in place rather than navigating into
|
||||
them — a folder's children render nested under it, siblings stay visible,
|
||||
and a toolbar button expands or collapses every folder at once (each
|
||||
unfetched level lazily calls `/git/tree` the first time it's opened).
|
||||
Opening a file entry shows the whole file (`/git/file`).
|
||||
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
|
||||
it is the cheap shape and the one you leave open while an agent works. Each
|
||||
row carries its own **stage** / **unstage** / **discard** buttons.
|
||||
- **Review** — every file's full diff, stacked, for reading a whole change-set.
|
||||
Clicking a file's header folds it shut; only clicking the **path itself**
|
||||
opens that file. (A header that navigated on any click made folding a
|
||||
target-practice exercise against one chevron.) One toolbar button folds or
|
||||
unfolds all of them. What is remembered is the set of *collapsed* paths, so
|
||||
a file that appears later is open by default; a lane switch clears it.
|
||||
- **One file** — clicking a file's path in any list opens just that file.
|
||||
**All files** returns to the list it was opened from — the same mode, at the
|
||||
same scroll offset — because reading a change-set is a walk down a long list
|
||||
and landing back at its top is the whole cost of opening one file.
|
||||
- **History** — the lane's recent commits (`GET /:id/git/log`); opening one
|
||||
shows that commit's diff (`?range=commit&sha=`).
|
||||
|
||||
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
||||
working tree, untracked files included as an all-added patch) and **vs base**
|
||||
(`<base>...HEAD` — what this lane's branch adds on top of `base_branch`; a lane
|
||||
with no base says so rather than guessing one). Opening a commit from History
|
||||
adds a third range, shown as a short-sha chip.
|
||||
|
||||
The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side**
|
||||
toggle, syntax highlighting from the tokenizer the conversation view already
|
||||
ships (`client/src/lib/highlight.ts` — no new dependency), the changed span
|
||||
inside a modified line picked out by a common prefix/suffix trim, **expand /
|
||||
collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the whole
|
||||
file, and a **whole file** switch that reads the file itself instead of its
|
||||
diff.
|
||||
|
||||
A diff scrolls on **both axes inside its own bounded box**, never by growing
|
||||
the panel: the single-file view takes the panel's remaining height, and each
|
||||
file in the stacked review list is capped, because a horizontal scrollbar at
|
||||
the bottom of a 2000-line container is only reachable after scrolling to the
|
||||
end of the file — the one place nobody needs it.
|
||||
|
||||
Side-by-side is two *independently scrolling* columns whose horizontal scroll
|
||||
is mirrored, not one wide row cut in half: a long line has to be able to run
|
||||
past its column's edge, and halves inside a single scroller instead painted
|
||||
over each other with nothing to scroll. The toolbar is two rows for the same
|
||||
reason — *where you are* (mode, refresh) above, *how you are looking at it*
|
||||
(range, layout, context, whole file) below — because one row wrapped mid-group
|
||||
in every panel narrow enough to sit beside a console.
|
||||
|
||||
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
|
||||
one file means nothing unless a commit can take the index as it stands, so the
|
||||
overview grows a commit box the moment anything is staged — it sends
|
||||
`only: "staged"`, never `add -A`, and it is absent with a clean index because
|
||||
there is nothing it could mean. (The git bar's own **Commit staged** is the
|
||||
same call from the lane card; its plain **Commit** is still stage-everything.) **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
already reporting as changed.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
expensive shapes. Diffs are capped server-side (300 files, 200 KB per file,
|
||||
1 MB per full read) and say so when a cap bit.
|
||||
|
||||
## Viewing lanes
|
||||
|
||||
List all lanes with their current status:
|
||||
@@ -511,7 +673,7 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
|
||||
|
||||
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
|
||||
|
||||
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `agenthub lanes add --pipeline <id>` is the same choice from the terminal.
|
||||
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the lane's detail header in Workspace.
|
||||
|
||||
For a lane that already exists, switch it from the terminal:
|
||||
|
||||
@@ -1315,6 +1477,28 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
|
||||
curl -X POST http://localhost:4820/api/lanes/5/stop
|
||||
```
|
||||
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
|
||||
|
||||
The git writes the lane card's action row and the Changes panel offer. See
|
||||
"Acting on a lane's working copy" above for what each one refuses and why.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "wip: from the dashboard"}'
|
||||
|
||||
# Only what the Changes panel staged
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "wip: staged only", "only": "staged"}'
|
||||
|
||||
# Irreversible, so `confirm` is required and the path must be one git
|
||||
# already reports as changed
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
|
||||
```
|
||||
|
||||
### clear
|
||||
|
||||
Reset the lane's pipeline progress, stages, notes, and inferred stage. The lane itself persists; only its recorded history is cleared.
|
||||
|
||||
Reference in New Issue
Block a user