From e1f433804c45900f921330de5343d799de68633a Mon Sep 17 00:00:00 2001
From: nntrivi2001
Date: Mon, 2 Nov 2026 12:06:39 +0700
Subject: [PATCH] feat(git): history graph with search, commit details, file
history, blame/browse at a revision, compare refs, and multi-commit
cherry-pick/revert with preview, pause banner and undo
---
ARCHITECTURE.md | 2 +
CLAUDE.md | 2 +-
.../src/components/lanes/LaneChangesPanel.tsx | 327 ++-----
.../lanes/__tests__/LaneChangesPanel.test.tsx | 99 ++-
client/src/components/lanes/git/BlameView.tsx | 156 ++++
.../components/lanes/git/BrowseAtRevision.tsx | 175 ++++
.../components/lanes/git/CherryPickDialog.tsx | 152 ++++
.../components/lanes/git/CommitDetails.tsx | 203 +++++
.../src/components/lanes/git/CommitGraph.tsx | 182 ++++
.../src/components/lanes/git/CompareRefs.tsx | 245 +++++
.../lanes/git/CreateBranchDialog.tsx | 128 +++
.../src/components/lanes/git/FileHistory.tsx | 194 ++++
client/src/components/lanes/git/GitModal.tsx | 50 +-
.../src/components/lanes/git/HistoryView.tsx | 751 ++++++++++++++++
.../src/components/lanes/git/PickBanner.tsx | 154 ++++
.../lanes/git/__tests__/HistoryView.test.tsx | 308 +++++++
.../lanes/git/__tests__/PickBanner.test.tsx | 88 ++
client/src/i18n/index.ts | 1 +
client/src/i18n/locales/en/gitHistory.json | 144 +++
client/src/i18n/locales/vi/gitHistory.json | 124 +++
client/src/lib/api.ts | 112 ++-
client/src/lib/gitHistory.ts | 211 +++++
client/src/lib/types.ts | 4 +
docs/API.md | 103 ++-
docs/LANES.md | 55 +-
openapi.yaml | 743 +++++++++++++++-
server/__tests__/git-history.test.js | 350 ++++++++
server/lib/git-history.js | 835 ++++++++++++++++++
server/lib/git-ops.js | 19 +
server/lib/git-pick.js | 286 ++++++
server/openapi-extra.js | 3 +
server/openapi-extra/git-history.js | 249 ++++++
server/openapi-extra/lanes.js | 24 +-
server/routes/lane-git-history.js | 91 ++
server/routes/lanes.js | 26 +-
35 files changed, 6277 insertions(+), 319 deletions(-)
create mode 100644 client/src/components/lanes/git/BlameView.tsx
create mode 100644 client/src/components/lanes/git/BrowseAtRevision.tsx
create mode 100644 client/src/components/lanes/git/CherryPickDialog.tsx
create mode 100644 client/src/components/lanes/git/CommitDetails.tsx
create mode 100644 client/src/components/lanes/git/CommitGraph.tsx
create mode 100644 client/src/components/lanes/git/CompareRefs.tsx
create mode 100644 client/src/components/lanes/git/CreateBranchDialog.tsx
create mode 100644 client/src/components/lanes/git/FileHistory.tsx
create mode 100644 client/src/components/lanes/git/HistoryView.tsx
create mode 100644 client/src/components/lanes/git/PickBanner.tsx
create mode 100644 client/src/components/lanes/git/__tests__/HistoryView.test.tsx
create mode 100644 client/src/components/lanes/git/__tests__/PickBanner.test.tsx
create mode 100644 client/src/i18n/locales/en/gitHistory.json
create mode 100644 client/src/i18n/locales/vi/gitHistory.json
create mode 100644 client/src/lib/gitHistory.ts
create mode 100644 server/__tests__/git-history.test.js
create mode 100644 server/lib/git-history.js
create mode 100644 server/lib/git-pick.js
create mode 100644 server/openapi-extra/git-history.js
create mode 100644 server/routes/lane-git-history.js
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 50830a2d..aade88e0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -391,6 +391,8 @@ graph TD
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- ` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. History rewrites and remote writes are dispatched to `lib/git-rewrite.js` (below); a stopped rebase lets through only `ALLOWED_WHILE_REBASING`. No stash beyond shelves. (`undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged.) `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-rewrite.js` | History rewrites and remote writes, each allowed because its recovery is a button too: `rebase` (+ `rebase-continue`/`-skip`/`-abort`; `merge-commit`/`merge-abort` map onto an open rebase), `rebase-interactive` (todo + messages as 0600 files in the repo's git dir, `cp` as sequence editor, `true` as editor, squash = `fixup` + `exec git commit --amend -F `), `reset` soft/mixed/keep/hard (hard: clean tree + branch name typed back), `undo-rewrite` (`reset --keep ` while HEAD is still `after`), `force-push` (`--force-with-lease` on the sha the plan showed, HEAD only, protected names refused unless `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`), `remote-branch-delete` (never the default or a protected branch), `remote-add`/`-rename`/`-remove` (URL through `validateRemoteUrl`). Every one: repo root (`ESUBDIR`), no open operation (`EMERGING`), the planned HEAD (`ESTALE`), pushed commits `EPUSHED` unless `force: true`. Plus the reads `rewritePlan` and `listRemotes` (passwords stripped) |
| `lib/git-conflicts.js` | The 3-way merge editor's read (`readConflict`: stages :1/:2/:3 by blob id for a path `ls-files -u` lists, the working file and its sha) and save (`conflict-save`: `lane-file-write.js` then `git add`, `EMARKERS` before writing), and the read-only reflog (`readReflog`) |
+| `lib/git-history.js` | The History tab's READS (via `routes/lane-git-history.js`, plus `/git/log`): the commit graph paged with `skip`, `all=1` and literal search (`q`, a SHA prefix, `author`, `path`) and typed ref badges; one commit's details; its `format-patch` (a secret file left out); a file's `--follow` history; blame at a revision; a tree at a revision; two refs compared (left/right commits, `A...B` or `A B` files, one file's hunks); the plan of a multi-commit pick/revert; a paused pick's sequencer state. Every revision is `SAFE_REV`-checked and resolved to a sha first, every path `lanePath`/`isSecretOnDisk`, walks time out at 20 s, outputs are capped |
+| `lib/git-pick.js` | The multi-commit `cherry-pick`/`revert` (`shas`, one git sequence in `pickPlan`'s order), the paused one's `pick-continue`/`pick-skip`/`pick-abort` (the last two `confirm:true`) and `undo-pick` (`reset --soft ` only while HEAD is the `to` the pick made and none of it is on a remote). Dispatched from `git-ops.js`'s `runGitAction`; same clean-tree, repo-root and `EMERGING` guards |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `/.playwright-mcp/proof///`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
diff --git a/CLAUDE.md b/CLAUDE.md
index 4fafd95d..56e1f74b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,7 +45,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument.
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
- **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`.
-- **The dashboard's git writes are these forty-three, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js` and `git-conflicts.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete` and `remote-remove` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
+- **The dashboard's git writes are these forty-seven, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js`, `git-conflicts.js` and `git-pick.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s), and the History tab's `pick-continue`, `pick-skip`, `pick-abort`, `undo-pick` (`git-pick.js`) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-remove`, `pick-skip`, `pick-abort` and `undo-pick` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open; with `shas: [...]` instead of `sha` — 1–50 non-merge commits, `git-pick.js` — they run as ONE git sequence in the order `GET /git/pick-plan` previews (a pick oldest→newest, a revert newest→oldest), a stop on a conflict is finished by `pick-continue` (`ECONFLICTS` while anything is unmerged), `pick-skip` or `pick-abort` (abort also ends a sequence whose *_HEAD is gone), none of them — nor `undo-pick` — is in `ALLOWED_WHILE_REBASING`, and the single-`sha` body stays git-ops.js's own path): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-pick` = `reset --soft ` takes back what a pick/revert just made (`confirm: true`, repo root, only while HEAD is still the `to` that pick produced — `ESTALE` —, `from` its ancestor 1–50 commits back with no merge between, none of them on a remote-tracking branch — `EPUSHED`, no override). `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log` — paged with `skip`, `all=1`, search by `q`/`author`/`path`, each commit with parents and ref badges —, `/git/commit`, `/git/patch` — a secret file's diff left out —, `/git/file-history` (`--follow`), `/git/blame-at`, `/git/tree-at` — a secret left out of the list —, `/git/compare`, `/git/pick-plan`, `/git/sequence` (`server/lib/git-history.js`, mounted by `server/routes/lane-git-history.js`, every revision resolved to a commit sha before git sees it, heavy walks timed out at 20 s; a rebase is never reported as a paused pick), `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`.
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx
index 68e17554..ecbaa6f4 100644
--- a/client/src/components/lanes/LaneChangesPanel.tsx
+++ b/client/src/components/lanes/LaneChangesPanel.tsx
@@ -18,7 +18,9 @@
* (`HtmlViewerModal`, its relative CSS, scripts and images read
* like downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
- * history — recent commits; opening one shows that commit's diff
+ * history — the commit graph (`git/HistoryView.tsx`: all branches, search,
+ * details, multi-select cherry-pick/revert, compare, browse at a
+ * revision); Show History on a file is `git/FileHistory.tsx`
* browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
@@ -59,9 +61,11 @@
* file list restores or deletes what was shelved — a git stash under the
* hood, its recovery always a button here.
*
- * History rewrites through its commit menu: Revert (any commit), Undo commit
- * (the top one; the server refuses a pushed one) and, while another branch's
- * history is shown, Cherry-pick onto this branch. The commit row's Amend box
+ * History rewrites through HistoryView's commit menu and selection: Revert and
+ * Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
+ * paused one finished from `PickBanner`), Undo commit (the top one; the
+ * server refuses a pushed one), Reset branch to here… and Edit commits after
+ * this one… (`GitTools`). The commit row's Amend box
* folds the ticked files into the last commit (none ticked: rewords it). All
* four ask first (`confirmDialog`).
*
@@ -153,7 +157,9 @@ import { useGitChanged } from "../../lib/gitChanged";
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
-import { commitGraph, type GraphRow } from "../../lib/commitGraph";
+import { HistoryView } from "./git/HistoryView";
+import { FileHistory } from "./git/FileHistory";
+import { PickBanner } from "./git/PickBanner";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -173,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
+/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
+ * merge and a rebase keep theirs). */
+const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
+
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
* modified (and renamed) blue. */
@@ -780,54 +790,6 @@ function ChangeTreeNodes({
);
}
-/** History's page size, and the most the server reads (`listCommits`). */
-const HISTORY_PAGE = 50;
-const HISTORY_MAX = 200;
-
-// The history's commit graph: one fixed-height row per commit so its lanes
-// join up row to row. Mid-tone hues that read on dark and light themes alike.
-const GRAPH_ROW = 34;
-const GRAPH_COL = 12;
-const GRAPH_COLORS = [
- "#3b82f6",
- "#22c55e",
- "#f59e0b",
- "#a855f7",
- "#ef4444",
- "#14b8a6",
- "#ec4899",
- "#84cc16",
-];
-
-function GraphCell({ row, width }: { row: GraphRow; width: number }) {
- const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
- const y = (f: number) => f * GRAPH_ROW;
- const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
- return (
-
- );
-}
-
export function LaneChangesPanel({
laneId,
session,
@@ -925,7 +887,6 @@ export function LaneChangesPanel({
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
- const [commits, setCommits] = useState(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState>({});
@@ -1095,30 +1056,8 @@ export function LaneChangesPanel({
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
- // is entered rather than alongside the diff nobody is looking at.
- // History reads HISTORY_PAGE commits, and Load more asks for the next page
- // (up to the server's 200).
- const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
- const [historyLoading, setHistoryLoading] = useState(false);
- useEffect(() => {
- if (mode !== "history") return;
- // A reply for the previous ref/file/page lands after the next request
- // started: drop it, or it overwrites the list the user is looking at.
- let live = true;
- setHistoryLoading(true);
- void api.lanes
- .gitLog(target, {
- limit: historyLimit,
- ...(historyRef ? { ref: historyRef } : {}),
- ...(historyFile ? { file: historyFile } : {}),
- })
- .then(forLane(laneId, (r) => live && setCommits(r.commits)))
- .catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
- .finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
- return () => {
- live = false;
- };
- }, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
+ // is entered rather than alongside the diff nobody is looking at. The commit
+ // graph and a file's history read themselves (git/HistoryView, FileHistory).
useEffect(() => {
if (mode !== "history") return;
let live = true;
@@ -1285,7 +1224,6 @@ export function LaneChangesPanel({
setTreeBusy(false);
setBusy(false);
setError(null);
- setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
@@ -1366,7 +1304,6 @@ export function LaneChangesPanel({
if (!ok) return;
setHistoryFile(path);
setHistoryRef(null);
- setCommits(null);
setMode("history");
});
};
@@ -1650,7 +1587,6 @@ export function LaneChangesPanel({
const fileCtx = useContextMenu();
const treeCtx = useContextMenu();
- const commitCtx = useContextMenu();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
@@ -1826,87 +1762,6 @@ export function LaneChangesPanel({
)}
- {commitCtx.menu && (
-
-
-
-
-
- {historyRef ? (
-
- ) : (
- <>
-
- {commits?.[0]?.sha === commitCtx.menu.item.sha && (
-
- )}
- {/* History rewrites (./git): both open a dialog that asks first. */}
-
-
- >
- )}
-
- )}
{
// The History button is the branch's; Show History, a file's.
- if (m === "history" && historyFile) {
- setHistoryFile(null);
- setCommits(null);
- }
+ if (m === "history" && historyFile) setHistoryFile(null);
setMode(m);
}}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
@@ -2729,7 +2581,17 @@ export function LaneChangesPanel({
+ );
+}
diff --git a/client/src/components/lanes/git/GitModal.tsx b/client/src/components/lanes/git/GitModal.tsx
index 77fd8c33..088b228e 100644
--- a/client/src/components/lanes/git/GitModal.tsx
+++ b/client/src/components/lanes/git/GitModal.tsx
@@ -4,7 +4,10 @@
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
* portal over the screen with `useDialog`'s focus contract, a title row with a
* close button, a scrolling body and an optional footer. `wide` widens it for
- * the three-pane merge editor; on a phone every size is the full width.
+ * the three-pane merge editor; `tall` gives a viewer (blame, browse at a
+ * revision, compare refs) the full height and a column body its own scrolling
+ * child can fill; on a phone every size is the full width. `ModalButton` is
+ * the History tools' footer button over the same BTN classes.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -26,6 +29,7 @@ export function GitModal({
onClose,
testId,
wide = false,
+ tall = false,
footer,
children,
}: {
@@ -33,6 +37,8 @@ export function GitModal({
onClose: () => void;
testId?: string;
wide?: boolean;
+ /** A viewer: the full height, and a flex-column body. */
+ tall?: boolean;
footer?: ReactNode;
children: ReactNode;
}) {
@@ -55,8 +61,8 @@ export function GitModal({
data-testid={testId}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
- wide ? "max-w-6xl" : "max-w-lg"
- }`}
+ wide || tall ? "max-w-6xl" : "max-w-lg"
+ } ${tall ? "h-[92vh]" : ""}`}
>
+ );
+}
diff --git a/client/src/components/lanes/git/__tests__/HistoryView.test.tsx b/client/src/components/lanes/git/__tests__/HistoryView.test.tsx
new file mode 100644
index 00000000..e0d45e9c
--- /dev/null
+++ b/client/src/components/lanes/git/__tests__/HistoryView.test.tsx
@@ -0,0 +1,308 @@
+/**
+ * @file Pins the History tab's graph view: rows with their graph cell and ref
+ * badges, the search box turning `author:`/`path:` into the log's filters,
+ * all branches, multi-select (checkbox and Shift-click) into a cherry-pick
+ * preview that lists the commits in the server's order and runs only on
+ * Confirm, a paused result handed to the overview, and Undo after a clean
+ * pick (asked first, sent with the from/to the pick answered). Also the pure
+ * `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
+ * here, Edit commits after this one) and Create tag here.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { HistoryView } from "../HistoryView";
+import { api } from "../../../../lib/api";
+import { confirmDialog } from "../../../../lib/confirm";
+import { parseSearch } from "../../../../lib/gitHistory";
+import type { LaneCommit } from "../../../../lib/types";
+
+vi.mock("../../../../lib/confirm", () => ({
+ confirmDialog: vi.fn(async () => true),
+}));
+vi.mock("../../../../lib/api", () => ({
+ api: {
+ lanes: {
+ gitLog: vi.fn(),
+ changes: vi.fn(),
+ gitRefAction: vi.fn(),
+ gitHistory: {
+ commit: vi.fn(),
+ patch: vi.fn(),
+ pickPlan: vi.fn(),
+ pick: vi.fn(),
+ act: vi.fn(),
+ compare: vi.fn(),
+ treeAt: vi.fn(),
+ },
+ },
+ },
+}));
+
+const sha = (c: string) => c.repeat(40);
+const commit = (c: string, over: Partial = {}): LaneCommit => ({
+ sha: sha(c),
+ short: c.repeat(7),
+ subject: `commit ${c}`,
+ author: "Ann",
+ date: "2026-10-01T00:00:00Z",
+ refs: [],
+ parents: [],
+ badges: [],
+ ...over,
+});
+// other: c -> b (feature branch), main: a (HEAD)
+const LOG = [
+ commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
+ commit("b", { parents: [sha("a")] }),
+ commit("a", {
+ parents: [],
+ badges: [
+ { kind: "local", name: "main", current: true },
+ { kind: "tag", name: "v1" },
+ ],
+ }),
+];
+
+const props = () => ({
+ target: 7,
+ branch: "main",
+ branches: ["main", "feature"],
+ historyRef: null,
+ onHistoryRef: vi.fn(),
+ reread: 0,
+ disabled: false,
+ headPushed: false,
+ onViewCommit: vi.fn(),
+ onOpenFileAtCommit: vi.fn(),
+ onUndoCommit: vi.fn(),
+ onResetHere: vi.fn(),
+ onEditAfter: vi.fn(),
+ onPaused: vi.fn(),
+ onChanged: vi.fn(),
+});
+
+beforeEach(() => {
+ vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
+ vi.mocked(api.lanes.changes)
+ .mockReset()
+ .mockResolvedValue({
+ available: true,
+ range: "commit",
+ base: null,
+ baseRef: null,
+ sha: sha("c"),
+ context: 3,
+ truncated: false,
+ files: [],
+ });
+ vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
+ vi.mocked(api.lanes.gitHistory.pick).mockReset();
+ vi.mocked(api.lanes.gitHistory.act).mockReset();
+ vi.mocked(api.lanes.gitHistory.pickPlan)
+ .mockReset()
+ .mockImplementation(async (_t, op, shas) => ({
+ available: true,
+ op,
+ // The server's order: oldest first for a pick.
+ commits: [...shas].reverse().map((s) => ({
+ sha: s,
+ short: s.slice(0, 7),
+ subject: `commit ${s[0]}`,
+ author: "Ann",
+ date: "",
+ })),
+ branch: "main",
+ clean: true,
+ repoRoot: true,
+ busy: false,
+ }));
+ vi.mocked(confirmDialog).mockClear();
+});
+
+describe("parseSearch", () => {
+ it("splits author: and path: from the message text", () => {
+ expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
+ q: "fix login",
+ author: "ann",
+ path: "src/a b.ts",
+ });
+ expect(parseSearch(" ")).toEqual({});
+ });
+});
+
+describe("HistoryView", () => {
+ it("draws one graph cell per commit with its ref badges", async () => {
+ render();
+ await screen.findByText("commit c");
+ expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
+ const badges = screen.getAllByTestId("ref-badge");
+ expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
+ expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
+ });
+
+ it("searches with filters and reads every branch", async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByText("commit c");
+ await user.type(screen.getByTestId("history-search"), "fix author:ann");
+ await waitFor(
+ () =>
+ expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
+ limit: 100,
+ q: "fix",
+ author: "ann",
+ }),
+ { timeout: 2000 }
+ );
+ await user.click(screen.getByRole("checkbox", { name: "All branches" }));
+ await waitFor(() =>
+ expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
+ limit: 100,
+ all: true,
+ q: "fix",
+ author: "ann",
+ })
+ );
+ });
+
+ it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
+ const user = userEvent.setup();
+ const p = { ...props(), historyRef: "feature" };
+ vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
+ action: "cherry-pick",
+ ok: true,
+ output: "",
+ conflicts: [],
+ paused: false,
+ from: sha("a"),
+ to: sha("e"),
+ commits: [
+ { sha: sha("d"), short: "ddddddd", subject: "commit b" },
+ { sha: sha("e"), short: "eeeeeee", subject: "commit c" },
+ ],
+ });
+ vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
+ action: "undo-pick",
+ ok: true,
+ output: "",
+ conflicts: [],
+ paused: false,
+ });
+ render();
+ await screen.findByText("commit c");
+ await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
+ // Shift-click extends from the anchor (c) to b.
+ fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
+ expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
+
+ await user.click(screen.getByTestId("history-pick-selected"));
+ const plan = await screen.findByTestId("pick-plan");
+ expect(
+ within(plan)
+ .getAllByRole("listitem")
+ .map((li) => li.textContent)
+ ).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
+ expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
+ expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
+ await user.click(screen.getByTestId("pick-confirm"));
+ await waitFor(() =>
+ expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
+ );
+ expect(p.onChanged).toHaveBeenCalled();
+
+ // Undo is offered, asked first, and sent with what the pick answered.
+ await user.click(await screen.findByTestId("pick-undo"));
+ expect(confirmDialog).toHaveBeenCalled();
+ await waitFor(() =>
+ expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
+ from: sha("a"),
+ to: sha("e"),
+ confirm: true,
+ })
+ );
+ await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
+ });
+
+ it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
+ const user = userEvent.setup();
+ const p = { ...props(), historyRef: "feature" };
+ vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
+ available: true,
+ op: "cherry-pick",
+ commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
+ branch: "main",
+ clean: false,
+ repoRoot: true,
+ busy: false,
+ });
+ render();
+ await screen.findByText("commit c");
+ await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
+ await user.click(screen.getByTestId("history-pick-selected"));
+ await screen.findByTestId("pick-plan");
+ expect(screen.getByTestId("pick-confirm")).toBeDisabled();
+ expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
+ action: "cherry-pick",
+ ok: true,
+ output: "",
+ conflicts: ["a.txt"],
+ paused: true,
+ });
+ await user.click(screen.getByTestId("history-pick-selected"));
+ await user.click(await screen.findByTestId("pick-confirm"));
+ await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
+ expect(screen.queryByTestId("pick-result")).toBeNull();
+ });
+
+ it("opens the details pane on a click and the full change on a double-click", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render();
+ await user.click(await screen.findByText("commit b"));
+ expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
+ expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
+ await user.dblClick(screen.getByText("commit c"));
+ expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
+ });
+
+ it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
+ action: "tag-create",
+ ok: true,
+ output: "",
+ });
+ render();
+ const menuOn = async (text: string) => {
+ await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
+ return screen.getByTestId("lane-changes-commit-menu");
+ };
+ // HEAD's own commit (main, the current branch): nothing after it to edit.
+ expect(
+ within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
+ ).toBeDisabled();
+ await user.keyboard("{Escape}");
+ await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
+ expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
+ await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
+ expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
+
+ await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
+ await user.type(screen.getByTestId("create-ref-name"), "v2");
+ await user.click(screen.getByTestId("create-ref-confirm"));
+ await waitFor(() =>
+ expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
+ tag: "v2",
+ from: sha("a"),
+ })
+ );
+ expect(p.onChanged).toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/lanes/git/__tests__/PickBanner.test.tsx b/client/src/components/lanes/git/__tests__/PickBanner.test.tsx
new file mode 100644
index 00000000..a6c080f3
--- /dev/null
+++ b/client/src/components/lanes/git/__tests__/PickBanner.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * @file Pins the paused cherry-pick/revert banner: it names the commit it
+ * stopped at and the step ("2 of 3"), Continue waits for every conflict to be
+ * resolved, Skip and Abort each need a second click and only then send
+ * `confirm: true`, and it draws nothing when no pick is open.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { PickBanner } from "../PickBanner";
+import { api } from "../../../../lib/api";
+
+vi.mock("../../../../lib/api", () => ({
+ api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
+}));
+
+const paused = (conflicts: string[]) => ({
+ available: true as const,
+ active: true as const,
+ operation: "cherry-pick" as const,
+ current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
+ remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
+ done: 1,
+ startedFrom: "a".repeat(40),
+ conflicts,
+});
+
+const props = () => ({
+ target: 7,
+ conflicts: 1,
+ reread: 0,
+ busy: false,
+ onBusy: vi.fn(),
+ onDone: vi.fn(),
+});
+
+beforeEach(() => {
+ vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
+ action: "pick-continue",
+ ok: true,
+ output: "",
+ conflicts: [],
+ paused: false,
+ commits: [],
+ });
+});
+
+describe("PickBanner", () => {
+ it("names the stop and its step, and Continue waits for the conflicts", async () => {
+ vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
+ render();
+ const banner = await screen.findByTestId("pick-banner");
+ expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
+ expect(banner).toHaveTextContent("Still to apply: ccccccc");
+ expect(screen.getByTestId("pick-continue")).toBeDisabled();
+ });
+
+ it("continues once resolved, and skips/aborts only on a second click", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
+ const p = props();
+ render();
+ await user.click(await screen.findByTestId("pick-continue"));
+ await waitFor(() =>
+ expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
+ );
+ expect(p.onDone).toHaveBeenCalled();
+
+ await user.click(screen.getByTestId("pick-skip"));
+ expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByTestId("pick-skip"));
+ expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
+
+ await user.click(screen.getByTestId("pick-abort"));
+ expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
+ await user.click(screen.getByTestId("pick-abort"));
+ expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
+ });
+
+ it("draws nothing when no pick is open", async () => {
+ vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
+ const { container } = render();
+ await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts
index dc7ca62a..66d7ea0b 100644
--- a/client/src/i18n/index.ts
+++ b/client/src/i18n/index.ts
@@ -120,6 +120,7 @@ export const i18nReady = i18n
"artifacts",
"gitRefs",
"gitTools",
+ "gitHistory",
],
defaultNS: "common",
interpolation: { escapeValue: false },
diff --git a/client/src/i18n/locales/en/gitHistory.json b/client/src/i18n/locales/en/gitHistory.json
new file mode 100644
index 00000000..bd955e0d
--- /dev/null
+++ b/client/src/i18n/locales/en/gitHistory.json
@@ -0,0 +1,144 @@
+{
+ "close": "Close",
+ "cancel": "Cancel",
+ "dismiss": "Dismiss",
+ "compare": "Compare",
+ "commit": "Commit",
+ "blame": "Blame",
+ "binary": "binary",
+ "running": "Working…",
+ "op.cherry-pick": "cherry-pick",
+ "op.revert": "revert",
+ "badge.local": "Branch {{name}}",
+ "badge.remote": "Remote branch {{name}}",
+ "badge.tag": "Tag {{name}}",
+ "badge.head": "HEAD (detached)",
+ "badge.current": "{{name}} — the branch you are on",
+ "searchPlaceholder": "Search message or SHA — author:name path:file",
+ "searchLabel": "Search the history",
+ "clearSearch": "Clear the search",
+ "allBranches": "All branches",
+ "noMatches": "No commit matches this search",
+ "selectCommit": "Select {{short}}",
+ "selectedN_one": "{{count}} commit selected",
+ "selectedN_other": "{{count}} commits selected",
+ "clearSelection": "Clear",
+ "mixedSelection": "Some of these are on this branch and some are not — select only one kind",
+ "mergeCommitRefused": "A merge commit can't be cherry-picked or reverted here — do it in a terminal",
+ "pickN_one": "Cherry-pick {{count}} commit onto {{branch}}",
+ "pickN_other": "Cherry-pick {{count}} commits onto {{branch}}",
+ "revertN_one": "Revert {{count}} commit",
+ "revertN_other": "Revert {{count}} commits",
+ "pickedN_one": "Cherry-picked {{count}} commit onto {{branch}}.",
+ "pickedN_other": "Cherry-picked {{count}} commits onto {{branch}}.",
+ "revertedN_one": "Reverted {{count}} commit on {{branch}}.",
+ "revertedN_other": "Reverted {{count}} commits on {{branch}}.",
+ "pausedConflicts_one": "The {{op}} stopped on {{count}} conflicted file — resolve it in Changes, then Continue",
+ "pausedConflicts_other": "The {{op}} stopped on {{count}} conflicted files — resolve them in Changes, then Continue",
+ "pausedEmpty": "The {{op}} stopped: a commit's changes are already on this branch — Skip it or Abort",
+ "undo": "Undo",
+ "undoHint": "Take these new commits back off the branch (only while they are not pushed)",
+ "undoTitle": "Undo this {{op}}?",
+ "undoBody_one": "The {{count}} new commit is removed from {{branch}}. Its changes stay staged, so nothing is lost. Refused if it was already pushed or the branch moved on.",
+ "undoBody_other": "The {{count}} new commits are removed from {{branch}}. Their changes stay staged, so nothing is lost. Refused if they were already pushed or the branch moved on.",
+ "undone_one": "Undone — {{count}} commit taken back, its changes are staged",
+ "undone_other": "Undone — {{count}} commits taken back, their changes are staged",
+ "showDetails": "Show details",
+ "browseFiles": "Browse files at this commit",
+ "compareWithCurrent": "Compare with current",
+ "copyPatch": "Copy patch",
+ "noPatch": "No patch for this commit",
+ "patchOmitted_one": "{{count}} file holding secrets was left out of the patch",
+ "patchOmitted_other": "{{count}} files holding secrets were left out of the patch",
+ "createBranchHere": "Create branch here…",
+ "detailsTitle": "Commit details",
+ "noDetails": "Could not read this commit",
+ "author": "Author",
+ "committer": "Committer",
+ "parents_one": "Parent",
+ "parents_other": "Parents",
+ "rootCommit": "none (first commit)",
+ "refs": "Refs",
+ "containedIn": "In",
+ "viewAllChanges": "View all changes",
+ "filesChanged_one": "{{count}} file changed",
+ "filesChanged_other": "{{count}} files changed",
+ "planning": "Working out the order…",
+ "planUnavailable": "This folder is not a git repository",
+ "pickTitle_one": "Cherry-pick {{count}} commit onto {{branch}}?",
+ "pickTitle_other": "Cherry-pick {{count}} commits onto {{branch}}?",
+ "revertTitle_one": "Revert {{count}} commit?",
+ "revertTitle_other": "Revert {{count}} commits?",
+ "pickIntro_one": "This commit is applied to {{branch}} as a new commit:",
+ "pickIntro_other": "These commits are applied to {{branch}} one by one, in this order, each as a new commit:",
+ "revertIntro_one": "A new commit that undoes this one is added to {{branch}}:",
+ "revertIntro_other": "A new commit undoing each of these is added to {{branch}}, newest first, in this order:",
+ "treeClean": "The working tree is clean.",
+ "blockDirty": "The working tree has uncommitted changes. Commit or discard them first, so an Abort can never take them with it.",
+ "blockSubdir": "This workspace is a subfolder of its repository; a cherry-pick or revert changes the whole repository, so run it from a workspace at the repo root.",
+ "blockBusy": "A merge, cherry-pick, revert or rebase is already in progress. Finish or abort it first.",
+ "pickResult": "Files in this workspace change right away. The original commits are not touched.",
+ "revertResult": "History is not rewritten: the reverted commits stay, and new commits undo them.",
+ "onConflict": "If a commit conflicts, the {{op}} pauses there: resolve the files in Changes, then Continue — or Skip that commit, or Abort to go back to exactly where you are now.",
+ "pausedAt": "{{op}} paused at {{short}} \"{{subject}}\" ({{step}} of {{total}}):",
+ "pausedBetween": "{{op}} paused ({{step}} of {{total}}):",
+ "pausedHelpConflicts_one": "resolve the conflicted file, then Continue — or Skip this commit, or Abort to go back to where you were.",
+ "pausedHelpConflicts_other": "resolve the {{count}} conflicted files, then Continue — or Skip this commit, or Abort to go back to where you were.",
+ "pausedHelpReady": "every conflict is resolved — Continue to commit it and go on, or Skip this commit, or Abort to go back to where you were.",
+ "pausedHelpResume": "Continue with the remaining commits, or Abort to go back to where you were.",
+ "stillToApply_one": "Still to apply: {{list}}",
+ "stillToApply_other": "Still to apply ({{count}}): {{list}}",
+ "continue": "Continue",
+ "continueBlocked": "Resolve every conflicted file first",
+ "skip": "Skip this commit",
+ "skipHint": "Drop this commit (and any resolution made for it) and go on with the next",
+ "skipConfirm": "Click again to skip",
+ "abort": "Abort",
+ "abortHint": "Stop and put the branch back exactly where it was before",
+ "abortConfirm": "Click again to abort the {{op}}",
+ "aborted": "{{op}} aborted — the branch is back where it was",
+ "sequenceDone_one": "{{op}} finished",
+ "sequenceDone_other": "{{op}} finished",
+ "status.A": "Added",
+ "status.M": "Modified",
+ "status.D": "Deleted",
+ "status.R": "Renamed",
+ "status.C": "Copied",
+ "renamedFrom": "renamed from {{from}}",
+ "noBlame": "No history to blame for this file",
+ "blameTitle": "Blame {{path}}",
+ "workingTree": "working tree",
+ "blameBefore": "Blame the version before this change",
+ "notCommitted": "not committed yet",
+ "browseTitle": "Files at {{short}}",
+ "pickAFile": "Pick a file on the left",
+ "binaryFile": "Binary file — not shown",
+ "compareTitle": "Compare",
+ "compareUnavailable": "This folder is not a git repository",
+ "compareA": "From (branch, tag or commit)",
+ "compareB": "To (branch, tag or commit)",
+ "swap": "Swap the two sides",
+ "modeThree": "Changes since they diverged (A...B)",
+ "modeTwo": "Difference between the two (A B)",
+ "tabFiles_one": "{{count}} file",
+ "tabFiles_other": "{{count}} files",
+ "tabOnly_one": "Only in {{name}} ({{count}})",
+ "tabOnly_other": "Only in {{name}} ({{count}})",
+ "noDifference": "No file differs",
+ "noCommitsSide": "No commit only on this side",
+ "backToFiles": "Back to the files",
+ "secretFile": "This file holds secrets and is not shown",
+ "noTextChange": "No text change to show (binary, or a mode change)",
+ "createBranchTitle": "New branch at {{short}}",
+ "createBranch": "Create branch",
+ "branchName": "Branch name",
+ "createBranchHint": "The branch is created at this commit; you stay on the branch you are on.",
+ "branchCreated": "Created branch {{branch}} at {{short}}",
+ "createTagHere": "Create tag here…",
+ "createTagTitle": "New tag at {{short}}",
+ "createTag": "Create tag",
+ "tagName": "Tag name",
+ "tagMessage": "Message (optional — makes an annotated tag)",
+ "createTagHint": "The tag stays on this machine until you push it from the Branches panel.",
+ "tagCreated": "Created tag {{name}} at {{short}}"
+}
diff --git a/client/src/i18n/locales/vi/gitHistory.json b/client/src/i18n/locales/vi/gitHistory.json
new file mode 100644
index 00000000..cf9e2eb0
--- /dev/null
+++ b/client/src/i18n/locales/vi/gitHistory.json
@@ -0,0 +1,124 @@
+{
+ "close": "Đóng",
+ "cancel": "Hủy",
+ "dismiss": "Ẩn",
+ "compare": "So sánh",
+ "commit": "Commit",
+ "blame": "Blame",
+ "binary": "nhị phân",
+ "running": "Đang chạy…",
+ "op.cherry-pick": "cherry-pick",
+ "op.revert": "revert",
+ "badge.local": "Nhánh {{name}}",
+ "badge.remote": "Nhánh remote {{name}}",
+ "badge.tag": "Tag {{name}}",
+ "badge.head": "HEAD (không ở nhánh nào)",
+ "badge.current": "{{name}} — nhánh bạn đang đứng",
+ "searchPlaceholder": "Tìm theo nội dung hoặc SHA — author:tên path:file",
+ "searchLabel": "Tìm trong lịch sử",
+ "clearSearch": "Xóa ô tìm",
+ "allBranches": "Mọi nhánh",
+ "noMatches": "Không có commit nào khớp",
+ "selectCommit": "Chọn {{short}}",
+ "selectedN_other": "Đã chọn {{count}} commit",
+ "clearSelection": "Bỏ chọn",
+ "mixedSelection": "Có commit đã nằm trên nhánh này, có commit thì chưa — hãy chỉ chọn một loại",
+ "mergeCommitRefused": "Không cherry-pick hay revert được merge commit ở đây — hãy làm trong terminal",
+ "pickN_other": "Cherry-pick {{count}} commit vào {{branch}}",
+ "revertN_other": "Revert {{count}} commit",
+ "pickedN_other": "Đã cherry-pick {{count}} commit vào {{branch}}.",
+ "revertedN_other": "Đã revert {{count}} commit trên {{branch}}.",
+ "pausedConflicts_other": "{{op}} dừng lại vì {{count}} file bị conflict — hãy giải quyết trong Changes rồi bấm Tiếp tục",
+ "pausedEmpty": "{{op}} dừng lại: thay đổi của một commit đã có sẵn trên nhánh này — hãy Bỏ qua hoặc Hủy",
+ "undo": "Hoàn tác",
+ "undoHint": "Gỡ các commit mới này khỏi nhánh (chỉ khi chúng chưa được push)",
+ "undoTitle": "Hoàn tác lần {{op}} này?",
+ "undoBody_other": "{{count}} commit mới sẽ bị gỡ khỏi {{branch}}. Thay đổi của chúng vẫn được giữ ở trạng thái staged nên không mất gì. Sẽ bị từ chối nếu đã push hoặc nhánh đã có commit mới hơn.",
+ "undone_other": "Đã hoàn tác {{count}} commit, thay đổi vẫn đang ở trạng thái staged",
+ "showDetails": "Xem chi tiết",
+ "browseFiles": "Xem các file ở commit này",
+ "compareWithCurrent": "So sánh với nhánh hiện tại",
+ "copyPatch": "Sao chép patch",
+ "noPatch": "Commit này không có patch",
+ "patchOmitted_other": "Đã bỏ {{count}} file chứa thông tin bí mật ra khỏi patch",
+ "createBranchHere": "Tạo nhánh tại đây…",
+ "detailsTitle": "Chi tiết commit",
+ "noDetails": "Không đọc được commit này",
+ "author": "Tác giả",
+ "committer": "Người commit",
+ "parents_other": "Commit cha",
+ "rootCommit": "không có (commit đầu tiên)",
+ "refs": "Ref",
+ "containedIn": "Có trong",
+ "viewAllChanges": "Xem mọi thay đổi",
+ "filesChanged_other": "{{count}} file thay đổi",
+ "planning": "Đang xếp thứ tự…",
+ "planUnavailable": "Thư mục này không phải git repo",
+ "pickTitle_other": "Cherry-pick {{count}} commit vào {{branch}}?",
+ "revertTitle_other": "Revert {{count}} commit?",
+ "pickIntro_other": "Lần lượt áp từng commit vào {{branch}} theo đúng thứ tự dưới đây, mỗi commit thành một commit mới:",
+ "revertIntro_other": "Thêm vào {{branch}} các commit mới để đảo ngược từng commit dưới đây, commit mới nhất trước:",
+ "treeClean": "Thư mục làm việc đang sạch.",
+ "blockDirty": "Thư mục làm việc còn thay đổi chưa commit. Hãy commit hoặc bỏ chúng trước, để lỡ có Hủy cũng không mất gì.",
+ "blockSubdir": "Workspace này là thư mục con của repo; cherry-pick hay revert thay đổi cả repo, nên hãy chạy từ workspace ở thư mục gốc của repo.",
+ "blockBusy": "Đang có một merge, cherry-pick, revert hoặc rebase dở dang. Hãy hoàn tất hoặc hủy nó trước.",
+ "pickResult": "File trong workspace thay đổi ngay. Các commit gốc giữ nguyên.",
+ "revertResult": "Lịch sử không bị viết lại: commit cũ vẫn còn, các commit mới sẽ đảo ngược chúng.",
+ "onConflict": "Nếu một commit bị conflict, {{op}} sẽ dừng ở đó: giải quyết file trong Changes rồi bấm Tiếp tục — hoặc Bỏ qua commit đó, hoặc Hủy để quay về đúng trạng thái hiện tại.",
+ "pausedAt": "{{op}} đang dừng ở {{short}} \"{{subject}}\" ({{step}}/{{total}}):",
+ "pausedBetween": "{{op}} đang dừng ({{step}}/{{total}}):",
+ "pausedHelpConflicts_other": "hãy giải quyết {{count}} file bị conflict rồi bấm Tiếp tục — hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
+ "pausedHelpReady": "đã giải quyết hết conflict — bấm Tiếp tục để commit và làm tiếp, hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
+ "pausedHelpResume": "bấm Tiếp tục để áp các commit còn lại, hoặc Hủy để quay về như trước.",
+ "stillToApply_other": "Còn lại ({{count}}): {{list}}",
+ "continue": "Tiếp tục",
+ "continueBlocked": "Hãy giải quyết hết các file bị conflict trước",
+ "skip": "Bỏ qua commit này",
+ "skipHint": "Bỏ commit này (kể cả phần đã giải quyết cho nó) và làm tiếp commit sau",
+ "skipConfirm": "Bấm lần nữa để bỏ qua",
+ "abort": "Hủy",
+ "abortHint": "Dừng lại và đưa nhánh về đúng như trước khi bắt đầu",
+ "abortConfirm": "Bấm lần nữa để hủy {{op}}",
+ "aborted": "Đã hủy {{op}} — nhánh đã về như trước",
+ "sequenceDone_other": "{{op}} xong",
+ "status.A": "Thêm mới",
+ "status.M": "Sửa",
+ "status.D": "Xóa",
+ "status.R": "Đổi tên",
+ "status.C": "Sao chép",
+ "renamedFrom": "đổi tên từ {{from}}",
+ "noBlame": "File này chưa có lịch sử để blame",
+ "blameTitle": "Blame {{path}}",
+ "workingTree": "bản đang làm",
+ "blameBefore": "Blame phiên bản trước thay đổi này",
+ "notCommitted": "chưa commit",
+ "browseTitle": "Các file ở {{short}}",
+ "pickAFile": "Chọn một file bên trái",
+ "binaryFile": "File nhị phân — không hiển thị",
+ "compareTitle": "So sánh",
+ "compareA": "Từ (nhánh, tag hoặc commit)",
+ "compareB": "Đến (nhánh, tag hoặc commit)",
+ "compareUnavailable": "Thư mục này không phải git repo",
+ "swap": "Đổi chỗ hai bên",
+ "modeThree": "Thay đổi kể từ lúc tách nhánh (A...B)",
+ "modeTwo": "Khác biệt giữa hai bên (A B)",
+ "tabFiles_other": "{{count}} file",
+ "tabOnly_other": "Chỉ có ở {{name}} ({{count}})",
+ "noDifference": "Không có file nào khác nhau",
+ "noCommitsSide": "Bên này không có commit riêng",
+ "backToFiles": "Quay lại danh sách file",
+ "secretFile": "File này chứa thông tin bí mật nên không hiển thị",
+ "noTextChange": "Không có thay đổi dạng văn bản (file nhị phân hoặc chỉ đổi quyền)",
+ "createBranchTitle": "Nhánh mới tại {{short}}",
+ "createBranch": "Tạo nhánh",
+ "branchName": "Tên nhánh",
+ "createBranchHint": "Nhánh được tạo tại commit này; bạn vẫn ở nhánh hiện tại.",
+ "branchCreated": "Đã tạo nhánh {{branch}} tại {{short}}",
+ "createTagHere": "Tạo tag tại đây…",
+ "createTagTitle": "Tag mới tại {{short}}",
+ "createTag": "Tạo tag",
+ "tagName": "Tên tag",
+ "tagMessage": "Lời nhắn (không bắt buộc — có lời nhắn sẽ thành annotated tag)",
+ "createTagHint": "Tag chỉ nằm trên máy này cho tới khi bạn push nó từ bảng Nhánh.",
+ "tagCreated": "Đã tạo tag {{name}} tại {{short}}"
+}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 70afe85c..493b8d64 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -434,6 +434,19 @@ import type {
ReportInput,
ReportRun,
} from "./types";
+import type {
+ BlameAt,
+ CommitDetails,
+ CommitPatch,
+ CompareFile,
+ CompareResult,
+ FileHistoryPage,
+ PickAction,
+ PickPlan,
+ PickResult,
+ PickSequence,
+ TreeAt,
+} from "./gitHistory";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
@@ -4397,13 +4410,108 @@ export const api = {
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
gitBlame: (id: GitTarget, path: string) =>
request(gitUrl(id, "/blame", new URLSearchParams({ path }))),
- gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
+ gitLog: (
+ id: GitTarget,
+ opts: {
+ limit?: number;
+ file?: string;
+ ref?: string;
+ /** The page's start (the graph's paging). */
+ skip?: number;
+ /** Every branch, remote branch and tag. */
+ all?: boolean;
+ /** Search: message text (or a SHA prefix), author, path. */
+ q?: string;
+ author?: string;
+ path?: string;
+ } = {}
+ ) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
// Another branch's history (cherry-pick's source); absent = HEAD's.
if (opts.ref) q.set("ref", opts.ref);
- return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
+ if (opts.skip) q.set("skip", String(opts.skip));
+ if (opts.all) q.set("all", "1");
+ if (opts.q) q.set("q", opts.q);
+ if (opts.author) q.set("author", opts.author);
+ if (opts.path) q.set("path", opts.path);
+ return request<{
+ available: boolean;
+ commits: LaneCommit[];
+ hasMore?: boolean;
+ filtered?: boolean;
+ }>(gitUrl(id, "/log", q));
+ },
+ /** The history reads and the multi-commit pick (server/lib/git-history.js). */
+ gitHistory: {
+ commit: (id: GitTarget, sha: string) =>
+ request(
+ gitUrl(id, "/commit", new URLSearchParams({ sha }))
+ ),
+ patch: (id: GitTarget, sha: string) =>
+ request(
+ gitUrl(id, "/patch", new URLSearchParams({ sha }))
+ ),
+ fileHistory: (id: GitTarget, path: string, opts: { skip?: number; limit?: number } = {}) => {
+ const q = new URLSearchParams({ path });
+ if (opts.skip) q.set("skip", String(opts.skip));
+ if (opts.limit) q.set("limit", String(opts.limit));
+ return request(gitUrl(id, "/file-history", q));
+ },
+ blameAt: (id: GitTarget, path: string, rev = "work") =>
+ request(
+ gitUrl(id, "/blame-at", new URLSearchParams({ path, rev }))
+ ),
+ treeAt: (id: GitTarget, rev: string, path = "") =>
+ request(
+ gitUrl(id, "/tree-at", new URLSearchParams({ rev, path }))
+ ),
+ compare: (id: GitTarget, a: string, b: string, mode: "three" | "two" = "three") =>
+ request(
+ gitUrl(id, "/compare", new URLSearchParams({ a, b, mode }))
+ ),
+ compareFile: (
+ id: GitTarget,
+ a: string,
+ b: string,
+ mode: "three" | "two",
+ path: string,
+ from?: string
+ ) => {
+ const q = new URLSearchParams({ a, b, mode, path });
+ if (from) q.set("from", from);
+ return request(gitUrl(id, "/compare", q));
+ },
+ pickPlan: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
+ request(
+ gitUrl(id, "/pick-plan", new URLSearchParams({ op, shas: shas.join(",") }))
+ ),
+ sequence: (id: GitTarget) =>
+ request(gitUrl(id, "/sequence")),
+ /** POST cherry-pick / revert with `shas` (git-pick.js). */
+ // Both announce `agenthub:git-changed`, like every other git write.
+ pick: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
+ requestAndAnnounce(
+ gitUrl(id, `/${op}`),
+ { method: "POST", body: JSON.stringify({ shas }) },
+ typeof id === "number" ? id : id.lane,
+ op
+ ),
+ /** POST pick-continue / pick-skip / pick-abort / undo-pick. */
+ act: (
+ id: GitTarget,
+ action: PickAction,
+ body: { confirm?: boolean; from?: string; to?: string } = {}
+ ) =>
+ requestAndAnnounce<
+ PickResult & { undone?: { sha: string; short: string; subject: string }[] }
+ >(
+ gitUrl(id, `/${action}`),
+ { method: "POST", body: JSON.stringify(body) },
+ typeof id === "number" ? id : id.lane,
+ action
+ ),
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
diff --git a/client/src/lib/gitHistory.ts b/client/src/lib/gitHistory.ts
new file mode 100644
index 00000000..7dfd23f5
--- /dev/null
+++ b/client/src/lib/gitHistory.ts
@@ -0,0 +1,211 @@
+/**
+ * @file Types of the Changes panel's history reads and multi-commit
+ * cherry-pick/revert (server/lib/git-history.js, server/lib/git-pick.js),
+ * plus the small pure helpers the history components share: parsing a search
+ * box into the log's filters and naming a badge's kind.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import type { LaneChangeFile } from "./types";
+
+/** A ref pointing at a commit, typed for its badge. */
+export interface RefBadge {
+ kind: "local" | "remote" | "tag" | "head";
+ name: string;
+ /** The branch HEAD is on. */
+ current?: boolean;
+}
+
+/** `GET /git/commit` — one commit in full. */
+export interface CommitDetails {
+ available: true;
+ sha: string;
+ short: string;
+ subject: string;
+ message: string;
+ author: string;
+ email: string;
+ date: string;
+ committer: string;
+ committerEmail: string;
+ committedAt: string;
+ parents: string[];
+ badges: RefBadge[];
+ isHead: boolean;
+ branches: string[];
+ tags: string[];
+ containsTruncated: boolean;
+}
+
+/** `GET /git/patch`. */
+export interface CommitPatch {
+ available: true;
+ sha: string;
+ patch: string;
+ omitted: string[];
+ truncated: boolean;
+}
+
+/** One version in `GET /git/file-history`. */
+export interface FileVersion {
+ sha: string;
+ short: string;
+ subject: string;
+ author: string;
+ date: string;
+ parents: string[];
+ /** A, M, R, D, C… */
+ status: string;
+ /** The file's name in THIS commit. */
+ path: string;
+ /** A rename's old name. */
+ from?: string;
+}
+
+export interface FileHistoryPage {
+ available: true;
+ path: string;
+ commits: FileVersion[];
+ hasMore: boolean;
+}
+
+/** `GET /git/blame-at`. */
+export interface BlameAt {
+ available: true;
+ path: string;
+ rev: string;
+ commits: Record<
+ string,
+ { short: string; author?: string; date?: string; summary?: string; previous?: string }
+ >;
+ lines: { sha: string | null; text: string }[];
+}
+
+export interface TreeAtEntry {
+ path: string;
+ name: string;
+ dir: boolean;
+ submodule: boolean;
+ size: number | null;
+}
+
+/** `GET /git/tree-at`. */
+export interface TreeAt {
+ available: true;
+ rev: string;
+ path: string;
+ entries: TreeAtEntry[];
+ truncated: boolean;
+}
+
+export interface CompareCommit {
+ sha: string;
+ short: string;
+ subject: string;
+ author: string;
+ date: string;
+ parents: string[];
+}
+
+/** `GET /git/compare` without `path`. */
+export interface CompareResult {
+ available: true;
+ a: string;
+ b: string;
+ mode: "three" | "two";
+ onlyA: CompareCommit[];
+ onlyB: CompareCommit[];
+ commitsTruncated: boolean;
+ files: (LaneChangeFile & { from?: string })[];
+ filesTruncated: boolean;
+}
+
+/** `GET /git/compare` with `path`. */
+export interface CompareFile {
+ available: true;
+ file: LaneChangeFile & { secret?: boolean };
+}
+
+export interface PlannedCommit {
+ sha: string;
+ short: string;
+ subject: string;
+ author: string;
+ date: string;
+}
+
+/** `GET /git/pick-plan`. */
+export interface PickPlan {
+ available: true;
+ op: "cherry-pick" | "revert";
+ commits: PlannedCommit[];
+ branch: string | null;
+ clean: boolean;
+ repoRoot: boolean;
+ busy: boolean;
+}
+
+/** `GET /git/sequence`. */
+export type PickSequence =
+ | { available: true; active: false }
+ | {
+ available: true;
+ active: true;
+ operation: "cherry-pick" | "revert";
+ current: { sha: string; short: string; subject: string } | null;
+ remaining: { sha: string; short: string; subject: string }[];
+ done: number;
+ startedFrom: string | null;
+ conflicts: string[];
+ };
+
+/** The answer of a multi-commit pick/revert and of the banner's buttons. */
+export interface PickResult {
+ action: string;
+ ok: true;
+ output: string;
+ from?: string | null;
+ to?: string | null;
+ commits?: { sha: string; short: string; subject: string }[];
+ conflicts: string[];
+ paused: boolean;
+ empty?: boolean;
+ applied?: PlannedCommit[];
+}
+
+export type PickAction = "pick-continue" | "pick-skip" | "pick-abort" | "undo-pick";
+
+/** The history's search, as the log's query parameters. */
+export interface HistoryFilter {
+ q?: string;
+ author?: string;
+ path?: string;
+}
+
+/**
+ * One search box into the log's filters, like GitLens' search: `author:x`
+ * and `path:x` (or `file:x`) are their own filters, the rest is message text
+ * (a hex word also finds that SHA prefix, server-side).
+ */
+export function parseSearch(text: string): HistoryFilter {
+ const out: HistoryFilter = {};
+ const rest: string[] = [];
+ for (const m of text.matchAll(/(\w+):("[^"]*"|\S+)|("[^"]*"|\S+)/g)) {
+ if (m[1]) {
+ const v = m[2]!.replace(/^"|"$/g, "");
+ const key = m[1].toLowerCase();
+ if (key === "author" || key === "by") out.author = v;
+ else if (key === "path" || key === "file") out.path = v;
+ else rest.push(m[0]);
+ } else rest.push(m[3]!.replace(/^"|"$/g, ""));
+ }
+ const q = rest.join(" ").trim();
+ if (q) out.q = q.slice(0, 200);
+ if (out.author) out.author = out.author.slice(0, 200);
+ return out;
+}
+
+/** Selected commits, in the order they appear in the list (newest first). */
+export function inListOrder(list: T[], picked: Set): T[] {
+ return list.filter((c) => picked.has(c.sha));
+}
diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts
index c97533a3..6c5a0cdf 100644
--- a/client/src/lib/types.ts
+++ b/client/src/lib/types.ts
@@ -2937,6 +2937,10 @@ export interface LaneCommit {
refs: string[];
/** Parent shas, first parent first — the history's commit graph. */
parents: string[];
+ /** Author e-mail (the history graph's tooltip). */
+ email?: string;
+ /** The refs pointing here, typed for the graph's badges (`gitHistory.ts`). */
+ badges?: import("./gitHistory").RefBadge[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
diff --git a/docs/API.md b/docs/API.md
index e544d822..4b2f3c00 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -886,10 +886,67 @@ that file.
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
— `400 ENOBASE` when neither exists — and `400 ENOFILECHANGE` for a `path` with no changes in that range.
+#### History: commit details, patch, file history, blame, browse, compare
+
+```http
+GET /api/lanes/:id/git/commit?sha=
+GET /api/lanes/:id/git/patch?sha=
+GET /api/lanes/:id/git/file-history?path=[&skip=][&limit=]
+GET /api/lanes/:id/git/blame-at?path=[&rev=work|]
+GET /api/lanes/:id/git/tree-at?rev=[&path=]
+GET /api/lanes/:id/git/compare?a=&b=[&mode=three|two][&path=[&from=]]
+GET /api/lanes/:id/git/pick-plan?op=cherry-pick|revert&shas=,
+GET /api/lanes/:id/git/sequence
+```
+
+The History tab's reads (`server/lib/git-history.js`, mounted by
+`server/routes/lane-git-history.js`). Read-only and unguarded like every
+`/git` read, each resolving its repo exactly like them (the lane row, or
+`?session=` / `?source=` / `?repo=`); `{available:false}` with HTTP **200**
+for a folder git cannot read. A revision is a sha or a ref name (no leading
+`-`, no `..`; `~n`/`^n` suffixes allowed) and is resolved to a commit before
+git sees it, else `400 EBADREV`. A path is lane-relative with no `..`
+(`400 EBADPATH`). `403 ESECRET` / `EFOREIGN` as for `/git/file`.
+
+- **`/git/commit`** → `{sha, short, subject, message, author, email, date,
+ committer, committerEmail, committedAt, parents, badges, isHead, branches,
+ tags, containsTruncated}` — the details pane; `branches`/`tags` contain the
+ commit (50 each). Its files are `/git/changes?range=commit&sha=`.
+- **`/git/patch`** → `{sha, patch, omitted, truncated}` — `git format-patch -1
+ --stdout --relative` over the commit's files inside the workspace (Copy
+ patch). A secret file's diff is NEVER in it: it is named in `omitted`.
+ 2 MB cap. A merge commit is `400 EMERGECOMMIT`.
+- **`/git/file-history`** → `{path, commits:[{sha, short, subject, author,
+ date, parents, status, path, from?}], hasMore}` — `git log --follow`: each
+ commit with the name the file had THERE (`from` = a rename's old name), so
+ a version's diff is `/git/changes?range=commit&sha=&path=`.
+- **`/git/blame-at`** → `{path, rev, commits: {: {short, author, date,
+ summary, previous?}}, lines: [{sha|null, text}]}` — blame at a commit (or
+ `work`), with each line's text; `previous` is the commit to blame next
+ ("before this change"). 1 MB cap (`400 ETOOBIG`), `400 ENOFILE`.
+- **`/git/tree-at`** → `{rev, path, entries:[{path, name, dir, submodule,
+ size}], truncated}` — one folder at a commit (Browse files), folders first,
+ 5000 entries; a secret file is left out of the list, and its content stays
+ refused by `/git/file?rev=`.
+- **`/git/compare`** → `{a, b, mode, onlyA, onlyB, commitsTruncated, files,
+ filesTruncated}` — the commits only in `a` / only in `b`
+ (`--left-right a...b`, 300 each) and the files that differ: `mode=three`
+ (default) is `a...b`, what `b` adds since they diverged; `mode=two` is
+ `a b`, the trees as they stand. With `path` (and a rename's `from`):
+ `{a, b, mode, file}` with that file's hunks (200 KB cap); a secret file is
+ `{secret: true, hunks: []}`.
+- **`/git/pick-plan`** → `{op, commits, branch, clean, repoRoot, busy}` — what
+ `cherry-pick`/`revert` with `shas` would run, in the order it would run it
+ (a pick oldest first, a revert newest first; ancestry, then date), for the
+ confirmation dialog. `400 EBADSHA` / `EMERGECOMMIT`.
+- **`/git/sequence`** → `{active:false}` or `{active:true, operation, current,
+ remaining, done, startedFrom, conflicts}` — the cherry-pick/revert that is
+ paused (the banner's "paused at X (2 of 5)"); a merge is not reported here.
+
#### Read a workspace's commits, a file, or the file tree
```http
-GET /api/lanes/:id/git/log?limit=30[&file=][&ref=]
+GET /api/lanes/:id/git/log?limit=30[&skip=][&all=1][&ref=][&q=][&author=][&path=]
GET /api/lanes/:id/git/blame?path=
GET /api/lanes/:id/git/file?path=[&rev=work|HEAD|]
PUT /api/lanes/:id/git/file {path, content, baseSha}
@@ -907,15 +964,25 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read —
except the file explorer's `/git/tree`, `/git/tree-all` and `/git/ls`, which
read the disk instead for a folder that is not a git repo (below).
-- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
- refs, parents}]}`, newest first in `--date-order` (no parent before its
- children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
+- **`/git/log`** → `{available, commits:[{sha, short, subject, author, email,
+ date, refs, parents, badges}], hasMore, filtered}`, newest first in
+ `--date-order` (no parent before its children; `parents` = parent shas,
+ first parent first) — the History tab's commit graph. `badges` are the refs
+ pointing at the commit, typed for drawing: `{kind: "local"|"remote"|"tag"|"head",
+ name, current?}` (`current` = the branch HEAD is on, `head` = a detached
+ HEAD; `refs/remotes/*/HEAD` left out). `limit` is clamped to 500 (default
+ 30) and `skip` pages it; `hasMore` says another page exists. `all=1` reads
+ every branch, remote branch and tag (`--exclude=refs/stash --all`). Search:
+ `q=` matches the message case-insensitively and LITERALLY (never a regex),
+ and a hex `q` also puts the commit with that SHA prefix first; `author=`
+ matches name or e-mail, literally; `path=` (alias `file=`) narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
(where the panel's Cherry-pick finds its commits): a local or
- remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
- `/git/changes?range=commit&sha=`.
+ remote-tracking branch that exists, else `400 ENOBRANCH`. `q`/`author` over
+ 200 characters are `400 EBADQUERY`; a walk is killed after 20 s. Reading
+ one commit's diff is `/git/changes?range=commit&sha=`.
- **`/git/blame`** → `{available, path, commits: {: {short, author,
date, summary}}, lines: [|null]}` — `git blame --porcelain` of the
working-tree file (Annotate); `null` = a line not committed yet. Same
@@ -1198,6 +1265,11 @@ cannot collide on `index.lock`.
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` |
| `cherry-pick` | `git cherry-pick ` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
| `revert` | `git revert --no-edit ` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
+| `cherry-pick` / `revert` with `shas` | ONE git sequence of 1–50 non-merge commits, in `/git/pick-plan`'s order (`server/lib/git-pick.js`) | `{"shas": ["9b3e74a", "c1d2e3f"]}` |
+| `pick-continue` | `git cherry-pick --continue` / `git revert --continue` once nothing is unmerged | `{}` |
+| `pick-skip` | `--skip` the commit the pick/revert stopped at (drops its resolutions) | `{"confirm": true}` |
+| `pick-abort` | `--abort`: back to where the pick/revert started | `{"confirm": true}` |
+| `undo-pick` | `git reset --soft ` after a pick/revert — its changes stay staged | `{"from": "", "to": "", "confirm": true}` |
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": ""}` |
| `amend` | `git commit --amend -m `, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "", "paths": ["..."]}` |
| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: · [ · ]" [-- ]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` |
@@ -1327,6 +1399,25 @@ cannot collide on `index.lock`.
failing signer) is undone the same way and answers git's own error. A
rebase stopped in a terminal makes every history action `409 EMERGING`;
`merge-commit`/`merge-abort` then continue/abort that rebase.
+- **Several commits at once** (`shas` instead of `sha`, `server/lib/git-pick.js`)
+ run as ONE git sequence — `git cherry-pick A B C`, `git revert --no-edit C B A`
+ — in the order `GET /git/pick-plan` shows (a pick oldest first, a revert
+ newest first), under the same guards (1–50 commits, none a merge, a clean
+ repo-root tree, nothing already open — `409 EMERGING`). The answer is
+ `{from, to, commits, conflicts, paused, applied}`: `paused: true` with
+ `conflicts[]` stopped at a conflict, with `empty: true` at a commit whose
+ changes are already here. A paused one shows on `GET /git/sequence` and is
+ finished by `pick-continue` (`409 ECONFLICTS` while anything is unmerged,
+ `409 EEMPTY` when the resolution changed nothing — skip it then),
+ `pick-skip` or `pick-abort` (both `confirm: true`, `400 ECONFIRM`; abort
+ puts the branch back at `from`), `409 ENOMERGE` when nothing is open.
+ `undo-pick {from, to, confirm: true}` takes a finished pick/revert back with
+ `reset --soft ` (the changes stay staged): only while HEAD is still
+ `to` (`409 ESTALE`), `from` is its ancestor 1–50 commits back with no merge
+ between (`400 EBADSHA` / `EMERGECOMMIT`), and none of those commits is on a
+ remote-tracking branch (`409 EPUSHED`). The single-`sha` body is unchanged.
+ While a rebase is open every one of these is `409 EMERGING`, and
+ `GET /git/sequence` does not report a rebase as a paused pick.
- **Undo commit and amend** rewrite the last commit, so both are
`409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE`
when HEAD is no longer the `sha` the request names (`400 EBADSHA` without
diff --git a/docs/LANES.md b/docs/LANES.md
index 3cf0f227..7fbbee38 100644
--- a/docs/LANES.md
+++ b/docs/LANES.md
@@ -395,7 +395,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=&archived=0&pinned_first=1`, the newest 20 — pinned sessions on top in pin order, then the ones with a live pane, then the ended ones — re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), its hover buttons **pin** it to the top of the list (`PATCH {pinned: true}` — `sessions.pinned_at`, order only; a pinned row shows a pin icon, and the same button, or **Pin**/**Unpin** in the row's right-click menu, takes it back) and **archive** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=&run=`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=&session=` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Stop archives, Sleep keeps** — every UI path that ends a live pane goes through `endRuns` (`client/src/lib/stopSession.ts`). **Stop** — a tab's **×**, a pane header's **×**, **Close tab** in a right-click menu — kills the pane AND archives the session it held (`DELETE /api/run/:id?archive=1`, which sets `sessions.archived_at` server-side and broadcasts `session_updated`), so a session the user closed leaves the workspace's list (the home's archived fold brings it back with **Unarchive**). It only hides: a session worktree is NOT removed here, unlike the row's own Archive button. The reverse holds too: a session row's **Archive** (sidebar row, Recents, its right-click menu) on a session a live pane holds is a **Stop** — same confirm, same `endRuns` — because archiving only the row once left the pane running with nothing on screen showing it. **Sleep** — the moon left of each × (AI panes only), and **Sleep** in the tab, pane and session right-click menus — kills the pane but keeps the session listed, so it can be opened and resumed later by sending it a message. Both ask first for an AI pane (it may be mid-turn); a shell closes at once. A pane that dies on its own (exit, crash, reboot) is not archived, and the CLI's `DELETE` without `archive` keeps its old behaviour.
-- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (Details, its buttons' actions, plus Copy link for a page — also on its `⋯` button) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
+- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View changes, Show details, Browse files, Compare with current, Copy commit SHA, message and patch, Create branch here, Create tag here, Cherry-pick, Revert or Undo commit, Reset branch to here and Edit commits after this one (several ticked commits: Cherry-pick N or Revert N). The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (Details, its buttons' actions, plus Copy link for a page — also on its `⋯` button) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **Chat view of a live pane** — each SESSION of a Claude, Codex, omp, Cursor or Antigravity pane (`CHAT_TOOLS` in `client/src/lib/chatUi.ts`; the last three learn their session id from their pollers' `claimToolSessions`, and the transcript route reads omp's journal, Cursor's agent transcript and agy's `transcript_full.jsonl` step log) shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — an Agent/Task step shows instead one muted line — its status as an icon, then type · model · time · tokens · tool calls — and its report as prose, with **View conversation ›**, which puts that subagent's own conversation in the report's place as a thread hung under the step by a line on the left (a nested `ConversationView` in its `compact` layout: its task as the prompt bubble, every step, its reply; as tall as it is up to 24rem, then it scrolls) (`client/src/components/conversation/AgentDetails.tsx`; the agent is found by the result's `agent` facts, the `agentId:` it names or the `tool_use_id` of `GET /api/sessions/:id/transcripts`, and a background one is closed by its ``), as does a click on a line of the background-agents card, under that line — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`) — or while the terminal itself shows the turn at work (its own busy line, `BUSY_LINE`/`readRunBusy` in `client/src/lib/panePaste.ts`: Claude's and Codex's `esc to interrupt`, agy's `Thinking… (… esc to cancel)`; for omp and Cursor, whose line is unknown, output drawn in the last 2 s), read every 700 ms while the pane is on screen, so a tool that sends no hooks gets it too —, an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. An image attached in the box reaches the transcript as its saved path — in a Claude pane as an `@` mention (`claudeMention` in `client/src/lib/promptAttach.ts`, which Claude reads at submit, an image as an image; a trailing space keeps its `@` suggester from taking the Enter), as is a file dropped or pasted on a Claude terminal and every file a new or resumed Claude session is started with: a bare pasted path became `[Image #n]` only when the TUI caught the paste in time, and stayed a plain `C:\…\drops\…png` on a Windows dashboard (psmux), while a path in the command-line prompt is never an image; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box; a Codex pane has **Permission mode** and **Model** buttons that type `/permissions` and `/model` — Codex's own numbered pickers (the model, then its reasoning level), answered on the menu card below, each row's description column read as its hint. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a permission prompt — a menu with a **Yes…** and a **No…** row: Claude's tool approval, Codex's command approval, the folder-trust question — is laid out as its own card (`PermissionMenuCard`): the tool and its command (Claude draws them over a dashed `╌` rule above the question, read as the menu's `detail`), the CLI's own answers as buttons, and **Deny with a note**, which picks the "No, and tell … differently" row and then sends the note as the next message once the CLI is back at its prompt; a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row — **Next** while more questions follow — is reached with the arrows and Enter; an AskUserQuestion that asks several questions shows its question tabs above the card, each ticked once answered, with ‹ › buttons that press ←/→ in the pane (`stepRunMenu`), **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. A subagent launched in the background returns at once (its result is "Async agent launched…"), so the turn ends while it still works: until a `` names its call, a live chat lists it under the conversation — its type, its description, how long it has run and, once its own transcript was read, the step it is on and its context size (↓, its newest prompt), like the terminal's footer (`backgroundAgents`/`BackgroundAgents`/`agentProgress` in `MessageList.tsx`; the tail is re-read every 4 s through the existing `GET /api/sessions/:id/transcript?agent_id=`, only the lines after those already read, and only while the card is on screen) — and the last turn reads **Replied in … · N agents still running** with a spinner instead of Done (issue #81). A launch older than 6 hours with no notification is taken as ended (a CLI that quit, a resumed old session). The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`), and within 300 ms of a `transcript_changed` of its session (`PUSH_FETCH_MS`: the server's projects-tree watcher saw that Claude transcript grow, `pushTranscript` in `server/index.js`, at most every 250 ms per session), and at most once a second while the terminal underneath draws (`notePaneOutput`/`onPaneOutput` in `client/src/lib/panePaste.ts`, fed by `TerminalView` — any tool, hooks or not) — so a reply appears as soon as Claude writes it, not at the next poll. The TUI draws no reply as it streams (it shows the spinner and a token count until the block is done), so the chat has no streaming text. The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one (Claude Code offers those tools to its newer models only with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1`, so `spawnRun` puts it in every Claude pane's env; a user's `settings.json` `env` can still set `"0"`): how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path.
- **Artifacts in the chat** — like Claude Desktop, a page, document, diagram, table or React component the model wrote (`isArtifact` in `client/src/lib/artifactView.ts`: `.svg`, `.md/.markdown/.mdx`, `.mmd/.mermaid`, `.csv/.tsv`, and a `.html/.htm` or `.jsx/.tsx` written whole by a Write — one it merely edited is project code, and so is an HTML page of the project itself: any `index.html`, or one under a `src/`, `public/`, `templates/`, `views/`, `pages/`, `app/`, `static/`, `dist/`, `build/`, `components/` or `layouts/` folder; those stay in **Edited N files**) gets an **artifact card** in the conversation (`ArtifactCard` in `MessageList.tsx`: an icon by kind, its name, "Web page / Document / Diagram / React component / Table · Click to open"), shown as soon as the file is made, even mid-turn; the turn's **Edited N files** card lists the other files. A click opens it in the chat's own side panel (`onOpenArtifact` → `PaneChat`, not the workspace's Changes panel), which the Outputs list opens too — an HTML file (from its card, the Outputs list or a path in a reply, in the workspace or not) opens the same view as a modal over the whole screen instead (`modal`): `FilePreview` in `client/src/components/run/ChatFilePreview.tsx` with **Preview | Code** (Code is the file highlighted with line numbers, `CodeBlock`), its **versions** (‹ Version n of m ›, rebuilt from the conversation's Write/Edit/MultiEdit inputs by `artifactVersions` — the transcript keeps those inputs up to 256 KB, `server/lib/transcript-message.js`; an edit to text the conversation never showed is a version of unknown content, the file on disk standing in for the newest), **Copy** (the text), **Download**, **Save to Artifacts** (a workspace file through `POST /api/artifacts/file` by its path, anything else — an older version, a file outside the workspace — through `POST /api/artifacts/file/upload`; shared to others from the Artifacts page), **Full screen** (over the whole page) and Close; clicking the name copies its path. Preview renders Markdown, a CSV as a table, an image, and runs HTML, Mermaid (pinned `mermaid@11.4.1`) and React (`react@18.3.1` + `@babel/standalone@7.26.4` + Tailwind's play CDN, a default-exported component, `react`/`react-dom` the only imports) in an `