diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 08a2d45a..1cffe24f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -358,7 +358,7 @@ graph TD | `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned | | `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live | | `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) | -| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. | +| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. | | `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation | | `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_ + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent | | `lib/lane-profile.js` | The stack seam. Resolves `/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash ` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/.log` and that broadcast. Hook names come from a fixed allowlist, never from a request | @@ -368,6 +368,8 @@ graph TD | `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config | | `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything | | `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | +| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` | +| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `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). 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`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `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/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 | @@ -468,6 +470,7 @@ graph TD ### Splash & loading UX - **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. +- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`. - **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). - **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. diff --git a/CLAUDE.md b/CLAUDE.md index a070490f..a88a724c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.17 (2026-09-18) + the run's permission mode defaulting to `auto` and the model picker sending ALIASES (`ALLOWED_PERMISSION_MODES` in `server/lib/pty-run.js` + `server/routes/run.js` gained `auto`, which is also the fallback `buildClaudeArgv` passes to `--permission-mode`, the `RunSetup` select's first option and `LaneConsolePane`'s initial state; and `RUN_MODEL_CHOICES` in `client/src/lib/api.ts` now holds `fable`/`opus`/`sonnet`/`haiku` instead of pinned ids — `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + `convertEol` being GONE from the xterm terminal (`TerminalView.tsx`) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (`server/lib/desktop-env.js`, merged into `spawnRun`'s tmux `-e` env) — a systemd user service has none of them, so `claude` in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (`sizeFromQuery` in `server/websocket.js`, `?cols=&rows=` on the `/ws-pty/:runId` URL built in `TerminalView.tsx`) — `attach()` was spawning `tmux attach-session` at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (`lane-empty` in `client/src/pages/Workspace.tsx`, `empty.title`/`empty.body` in `lanes.json` replacing `emptyState`) — the lane strip, add tile included, now renders only when a lane exists + the **Create/update readme file** action living in the lane's **Tools** menu (`LaneToolsMenu` in `client/src/components/lanes/LaneCard.tsx`, `actions.writeDocs*` in `lanes.json`) instead of being a checkbox in the Add-lane modal — same `api.run.start({initialPrompt: "/sg-all:sg-md-writing"})`, claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (`TerminalView.tsx`: `term.focus()` after the first fit, so typing reaches tmux without clicking the pane, and a new `onExit` prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's `exit` frame; `LaneConsolePane` runs `newRun()` there, so a double Ctrl+C out of `claude` lands back on the run setup form instead of a dead pane behind a **New run** button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.8.1 (2026-09-23) + hook ingest dropping a byte-identical redelivery of the session's latest event (`findRecentDuplicateEvent` in `server/db.js`, guard in `POST /api/hooks/event`) + the Add-lane modal having NO pipeline picker and no Advanced disclosure (`AddLaneModal.tsx`: every lane is created on the server's own `default`, changed afterwards from the lane's detail header or at creation via `agenthub lanes add --pipeline `; the router radio is the one per-lane extra left and it sits in the form) + the lane-detail header dropping its stage and `auto: ` chips (the PipelineMap directly below already draws both) + the Changes panel's ONE resize control being icon-only, drawing the width the next click lands on + `client/src/components/Tooltip.tsx` — `GlobalTooltip`, mounted once in `App.tsx`, upgrades EVERY native `title` in the app into an anchored, themed bubble (parks the attribute in `data-tip` while the bubble is up and restores it after, so the accessible name and the no-JS fallback survive; `data-tip-off` opts out, `Tip.tsx` shares its `TOOLTIP_SURFACE`) — do NOT add a per-call-site tooltip wrapper, keep writing `title=` + the lane git surface of issues #34 and #35 — `server/lib/git-diff.js` (a lane's changes as parsed hunks, `range=work|branch|commit`, a caller-chosen `context`, three caps, plus `listCommits`/`readFileAt`/`listPaths`/`listTree`) behind `GET /api/lanes/:id/git/changes|log|file|ls|tree`, `server/lib/git-ops.js` (fetch/pull/push/stage/unstage/discard/commit/switch/branch-create/branch-delete — a per-file path must be one `git status` reports as changed, and `discard` needs `confirm: true`) behind `POST /api/lanes/:id/git/:action`, the Changes panel's five modes (**Files** first) with a staged-only commit box in its overview and per-file collapse in its review list (the header folds, only the PATH opens the file; plus collapse/expand-all) and a **back** that returns to the originating mode AND scroll offset, ONE header control that steps narrow/50-50/70-30/closed (`agenthub.workspace.changesSize` beside `changesOpen`), and its VS Code-shaped viewer (`LaneDiffView.tsx`: a diff bounded to its own box and scrolling on BOTH axes — a horizontal bar at the foot of a 2000-line container is only reachable at the end of the file — unified/side-by-side as two independently scrolling columns with MIRRORED `scrollLeft` — halves inside one scroller painted over each other with nothing to scroll — highlighting reused from `client/src/lib/highlight.ts`, intra-line emphasis, expand AND collapse context, whole-file), the Add-lane modal's third branch answer **Current branch** (it switches the modal to Repo mode — a lane that simply IS the repo on `main`, which a worktree could not do anyway because git holds one worktree per branch) and its `prefill` prop, driven by the git bar's **New lane here**, `gitFacts` gaining `hasRemote`/`upstream`/`ahead`/`behind`, `GET /api/lanes/branches` gaining `worktrees`, `GET /api/run/cwds` gaining a `repo` kind (the `source_repo` of existing lanes), the Add-lane modal's new/existing branch toggle + worktree list + Advanced disclosure (`AddLaneModal.tsx`), and the two new client components `LaneGitBar.tsx` (in `LaneCard`) and `LaneChangesPanel.tsx` (the collapsible Changes card beside `lane-detail` in `Workspace.tsx`, `agenthub.workspace.changesOpen`) + v1.6.17 (2026-09-18) + the run's permission mode defaulting to `auto` and the model picker sending ALIASES (`ALLOWED_PERMISSION_MODES` in `server/lib/pty-run.js` + `server/routes/run.js` gained `auto`, which is also the fallback `buildClaudeArgv` passes to `--permission-mode`, the `RunSetup` select's first option and `LaneConsolePane`'s initial state; and `RUN_MODEL_CHOICES` in `client/src/lib/api.ts` now holds `fable`/`opus`/`sonnet`/`haiku` instead of pinned ids — `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + `convertEol` being GONE from the xterm terminal (`TerminalView.tsx`) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (`server/lib/desktop-env.js`, merged into `spawnRun`'s tmux `-e` env) — a systemd user service has none of them, so `claude` in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (`sizeFromQuery` in `server/websocket.js`, `?cols=&rows=` on the `/ws-pty/:runId` URL built in `TerminalView.tsx`) — `attach()` was spawning `tmux attach-session` at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (`lane-empty` in `client/src/pages/Workspace.tsx`, `empty.title`/`empty.body` in `lanes.json` replacing `emptyState`) — the lane strip, add tile included, now renders only when a lane exists + the **Create/update readme file** action living in the lane's **Tools** menu (`LaneToolsMenu` in `client/src/components/lanes/LaneCard.tsx`, `actions.writeDocs*` in `lanes.json`) instead of being a checkbox in the Add-lane modal — same `api.run.start({initialPrompt: "/sg-all:sg-md-writing"})`, claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (`TerminalView.tsx`: `term.focus()` after the first fit, so typing reaches tmux without clicking the pane, and a new `onExit` prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's `exit` frame; `LaneConsolePane` runs `newRun()` there, so a double Ctrl+C out of `claude` lands back on the run setup form instead of a dead pane behind a **New run** button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -37,6 +37,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep. - **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. - **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/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`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv 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 attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. +- **The dashboard's git writes are the boring ten, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `commit`, `switch`, `branch-create`, `branch-delete` — nothing else. **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` is the ONE irreversible action and it refuses to run without `confirm: true`; do not add a second destructive action without the same gate. `branch-create` never switches (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, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. Do not add merge, rebase, reset, stash or conflict resolution here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and `reset` already lives behind `worktree.js`'s three-check destroy guard. 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/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. - **A lane's card position is a viewer preference; its name is a lane fact.** The Workspace strip's drag/Alt-arrow order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. diff --git a/README.md b/README.md index 33e60898..1079e3fa 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ Internal build — all rights reserved. terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own setting. **This is the part that needs `tmux` installed** — see [Prerequisites](#prerequisites). +- **Git, from the dashboard.** Each lane card shows its branch, `↑ahead ↓behind` + and its uncommitted files, with buttons for the ordinary moves: fetch, pull + (always `--ff-only`), push, commit-all, and switching branch on a clean tree. + Beside the lane sits a collapsible **Changes** panel — an overview of the + changed files, a full review of every diff, or one file at a time, comparing + either uncommitted work or the whole branch against its base. There is no + merge/rebase/reset button by design: recovery from those needs a terminal. - **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own slot, ports and services per lane (`agenthub lanes up|down|logs`). - **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a @@ -278,7 +285,9 @@ lane. This is resource namespacing on the host, not a container: lanes run as th same user and share the network. [`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the -preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full +preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`, +`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`, +`POST /:id/git/:action`), and the full runtime/profile contract. ## Tests diff --git a/client/README.md b/client/README.md index 010a94c2..f8041660 100644 --- a/client/README.md +++ b/client/README.md @@ -726,6 +726,25 @@ flowchart LR Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button. +#### Tooltips (`components/Tooltip.tsx`) + +`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the +app the same designed tooltip — there is no wrapper to add at the call site. +It listens at the document, and on hover (after a 350 ms rest) or on keyboard +focus (immediately) it takes the element's native `title`, shows it as an +anchored bubble with an arrow, and puts the attribute back when the bubble +closes. Consequences worth knowing: + +- **Write `title="…"` exactly as before.** That is the whole API. A control + keeps its native tooltip, and its accessible name, when JS has not run. +- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted. +- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible + in light mode; it flips above/below, clamps to the viewport, and closes on + scroll, resize, pointer-down or `Escape`. +- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for + expanding a truncated **number** inside a line of text, but draws the same + surface so the two read as one thing. + --- ## Utilities diff --git a/client/src/App.tsx b/client/src/App.tsx index 1fd0448e..33575062 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -60,6 +60,7 @@ import { useCallback } from "react"; import { Layout } from "./components/Layout"; import { DocumentTitle } from "./components/DocumentTitle"; import { SplashScreen } from "./components/SplashScreen"; +import { GlobalTooltip } from "./components/Tooltip"; import { Dashboard } from "./pages/Dashboard"; import { History } from "./pages/History"; import { SessionDetail } from "./pages/SessionDetail"; @@ -87,6 +88,9 @@ export default function App() { return ( <> + {/* One renderer gives every `title` in the app the designed bubble — + see components/Tooltip.tsx for why it is not a wrapper component. */} + diff --git a/client/src/components/Tip.tsx b/client/src/components/Tip.tsx index 9e32eeb3..e2fd7ee0 100644 --- a/client/src/components/Tip.tsx +++ b/client/src/components/Tip.tsx @@ -4,6 +4,13 @@ * by {@link StatCard} for raw metric values and anywhere a compact display needs * a full-precision expansion without cluttering the layout. * + * ## Look + * The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide + * {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip + * are visibly one thing. It stays cursor-following (and not anchored like the + * global one) because it expands a NUMBER inside a line of text, where the + * anchor is a few characters wide and the cursor is the real subject. + * * ## Portal rendering * Tooltip content is portaled to `document.body` with `position: fixed` so * parent `overflow: hidden` cannot clip it. Placement flips left/up when the @@ -56,6 +63,7 @@ import { useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; +import { TOOLTIP_SURFACE } from "./Tooltip"; /** Props for {@link Tip}. */ interface TipProps { @@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps) {children} {show && createPortal( -
+
{raw}
, document.body diff --git a/client/src/components/Tooltip.tsx b/client/src/components/Tooltip.tsx new file mode 100644 index 00000000..c495c646 --- /dev/null +++ b/client/src/components/Tooltip.tsx @@ -0,0 +1,200 @@ +/** + * @file Tooltip.tsx + * @description The dashboard's tooltip look, and the one renderer that gives it + * to every control in the app. + * + * ## Why a global renderer instead of a wrapper component + * The UI already labels its icon-only controls with the native `title` + * attribute — over two hundred of them. A `` wrapper would mean + * touching every one of those call sites to get a designed bubble, and the + * next `title=` someone writes would be back to the browser's grey box. + * {@link GlobalTooltip} instead listens once at the document: on hover or + * keyboard focus it takes the element's `title`, suppresses the native tooltip + * by removing the attribute for as long as the bubble is up, and restores it + * on the way out. Nothing else in the app has to know it exists, and a control + * keeps its native tooltip (and its accessible name) when JS has not run. + * + * ## The look + * Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is + * hard to read and lands over the thing it describes. It flips above/below and + * clamps to the viewport, carries an arrow pointing at its anchor, fades in + * after a short delay so a cursor crossing a toolbar does not strobe, and is + * built from the theme's own tokens so it is legible in light mode too (the + * old bubble was a hardcoded near-black). + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +/** The shared bubble surface, so every tooltip in the app reads as one thing. */ +export const TOOLTIP_SURFACE = + "pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " + + "text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " + + "whitespace-pre-wrap break-words"; + +/** How long the pointer must rest on a control before its tooltip appears. */ +const SHOW_DELAY_MS = 350; +/** Gap between the control and the bubble, and the minimum viewport margin. */ +const GAP = 8; + +/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */ +const SELECTOR = "[title]:not([data-tip-off])"; + +type Anchored = { text: string; rect: DOMRect }; +type Placed = { left: number; top: number; arrow: number; above: boolean }; + +/** + * Mount ONCE, near the router root. Every `title` in the app becomes a + * designed tooltip; nothing else has to change. + */ +export function GlobalTooltip() { + const [tip, setTip] = useState(null); + const [pos, setPos] = useState(null); + const bubble = useRef(null); + // The element whose `title` is currently parked in `data-tip`, so it can be + // put back even if the pointer never leaves it (a route change, a re-render + // that unmounts it, Escape). + const parked = useRef(null); + const timer = useRef(undefined); + + const restore = useCallback(() => { + window.clearTimeout(timer.current); + const el = parked.current; + if (el) { + const text = el.dataset.tip; + if (text !== undefined) { + el.setAttribute("title", text); + delete el.dataset.tip; + } + parked.current = null; + } + setTip(null); + setPos(null); + }, []); + + const open = useCallback( + (el: HTMLElement, delay: number) => { + const text = (el.getAttribute("title") || "").trim(); + if (!text) return; + restore(); + timer.current = window.setTimeout(() => { + // Re-read at fire time: the control may have been re-rendered, removed + // or had its title changed while the delay was running. + if (!el.isConnected) return; + const current = (el.getAttribute("title") || "").trim(); + if (!current) return; + el.dataset.tip = current; + el.removeAttribute("title"); + parked.current = el; + setTip({ text: current, rect: el.getBoundingClientRect() }); + }, delay); + }, + [restore] + ); + + useEffect(() => { + const target = (e: Event) => { + const node = e.target; + return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null; + }; + const onOver = (e: PointerEvent) => { + const el = target(e); + if (el) open(el, SHOW_DELAY_MS); + }; + const onOut = (e: PointerEvent) => { + // Moving between children of the same control must not flicker. + const el = target(e); + const to = e.relatedTarget; + if (el && to instanceof Node && el.contains(to)) return; + if (el || parked.current) restore(); + }; + // Keyboard users get the same tooltip, immediately — a focus ring that + // reveals nothing is the whole reason icon-only buttons are hard to learn. + const onFocus = (e: FocusEvent) => { + const el = target(e); + if (el) open(el, 0); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") restore(); + }; + + document.addEventListener("pointerover", onOver); + document.addEventListener("pointerout", onOut); + document.addEventListener("pointerdown", restore); + document.addEventListener("focusin", onFocus); + document.addEventListener("focusout", restore); + document.addEventListener("keydown", onKey); + // A bubble anchored to a rect is wrong the moment anything moves. + window.addEventListener("scroll", restore, true); + window.addEventListener("resize", restore); + return () => { + document.removeEventListener("pointerover", onOver); + document.removeEventListener("pointerout", onOut); + document.removeEventListener("pointerdown", restore); + document.removeEventListener("focusin", onFocus); + document.removeEventListener("focusout", restore); + document.removeEventListener("keydown", onKey); + window.removeEventListener("scroll", restore, true); + window.removeEventListener("resize", restore); + restore(); + }; + }, [open, restore]); + + // Placed after the first paint, when the bubble's real size is known — + // measuring it is the only way to flip and clamp without guessing. + useEffect(() => { + if (!tip || !bubble.current) return; + const { width, height } = bubble.current.getBoundingClientRect(); + const vw = document.documentElement.clientWidth; + const vh = window.innerHeight; + const centre = tip.rect.left + tip.rect.width / 2; + + let left = centre - width / 2; + left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP)); + // Below by default; above only when below would run off the screen AND + // above actually fits, so a control at the very bottom still gets a bubble. + const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP; + + setPos({ + left, + top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP, + // The arrow points at the control even after the bubble was clamped. + arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)), + above, + }); + }, [tip]); + + if (!tip) return null; + + return createPortal( +
+ {tip.text} + {/* Two borders only: a fully bordered diamond draws its other two + edges straight across the bubble it is attached to. */} + +
, + document.body + ); +} + +export default GlobalTooltip; diff --git a/client/src/components/__tests__/Tooltip.test.tsx b/client/src/components/__tests__/Tooltip.test.tsx new file mode 100644 index 00000000..5d68e5da --- /dev/null +++ b/client/src/components/__tests__/Tooltip.test.tsx @@ -0,0 +1,108 @@ +/** + * @file Tooltip.test.tsx + * @description Pins the app-wide tooltip renderer: a control's native `title` + * becomes the designed bubble on hover and on keyboard focus, the native + * tooltip is suppressed only while the bubble is up and the attribute is put + * back afterwards (it is the control's accessible name), and `data-tip-off` + * opts out. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { GlobalTooltip } from "../Tooltip"; + +function setup(extra?: React.ReactNode) { + return render( + <> + + + {extra} + + ); +} + +const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true }); +const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true }); + +beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); +afterEach(() => vi.useRealTimers()); + +const settle = async () => { + await act(async () => { + vi.advanceTimersByTime(400); + }); +}; + +describe("GlobalTooltip", () => { + it("shows the bubble after a delay and takes the native title while it is up", async () => { + setup(); + const button = screen.getByRole("button", { name: "refresh" }); + + hover(button); + // Nothing yet: a cursor crossing a toolbar must not strobe every control. + expect(screen.queryByTestId("global-tooltip")).toBeNull(); + + await settle(); + expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy"); + expect(button).not.toHaveAttribute("title"); + }); + + it("hides and gives the title back on the way out", async () => { + setup(); + const button = screen.getByRole("button", { name: "refresh" }); + hover(button); + await settle(); + + unhover(button); + expect(screen.queryByTestId("global-tooltip")).toBeNull(); + expect(button).toHaveAttribute("title", "Refresh the working copy"); + }); + + it("opens immediately on keyboard focus and closes on Escape", async () => { + setup(); + const button = screen.getByRole("button", { name: "refresh" }); + + fireEvent.focusIn(button); + await act(async () => { + vi.advanceTimersByTime(0); + }); + expect(screen.getByTestId("global-tooltip")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByTestId("global-tooltip")).toBeNull(); + expect(button).toHaveAttribute("title", "Refresh the working copy"); + }); + + it("leaves a data-tip-off control on the browser's own tooltip", async () => { + setup( + + ); + const opted = screen.getByRole("button", { name: "opted out" }); + + hover(opted); + await settle(); + expect(screen.queryByTestId("global-tooltip")).toBeNull(); + expect(opted).toHaveAttribute("title", "Raw value"); + }); + + it("reads the title through a child element, and ignores an empty one", async () => { + setup( + + ); + + hover(screen.getByTestId("blank-child")); + await settle(); + expect(screen.queryByTestId("global-tooltip")).toBeNull(); + + const button = screen.getByRole("button", { name: "refresh" }); + button.innerHTML = "refresh"; + hover(screen.getByTestId("deep")); + await settle(); + expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy"); + }); +}); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 2063da90..20516731 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -25,7 +25,19 @@ import { CwdAutocomplete } from "../run/RunSetup"; import { FolderBrowseModal } from "./FolderBrowseModal"; import { api } from "../../lib/api"; import type { AiRouter, CwdSuggestion } from "../../lib/api"; -import type { Lane, LaneTool } from "../../lib/types"; +import type { Lane, LaneTool, LaneWorktree } from "../../lib/types"; + +/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only. + * Nothing is sent from it: an empty branch field means "let the server name + * it", which is how `LANE_BRANCH_PREFIX` keeps working. */ +function slugPreview(title: string) { + return title + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} /** Polls the lane until the background `git worktree add` finishes (status * leaves "provisioning"), or gives up after `timeoutMs`. Returns the final @@ -77,11 +89,16 @@ export function AddLaneModal({ onClose, onAdded, cwdSuggestions, + prefill, }: { open: boolean; onClose: () => void; /** Called with the newly created (possibly still-provisioning) lane. */ onAdded: (lane: Lane) => void; + /** Fields to open with, for "new lane from this repo" in a lane's git bar. + * A branch here means the sibling worktree checks out THAT branch, which + * is the existing-branch mode — the reason that mode exists (issue #34). */ + prefill?: { sourceRepo?: string; branch?: string } | null; /** The same suggestion list the Run form already fetched (dashboard cwd, * home, recently-used paths) — reused rather than fetched a second time. */ cwdSuggestions: CwdSuggestion[]; @@ -92,13 +109,17 @@ export function AddLaneModal({ const [title, setTitle] = useState(""); const [branch, setBranch] = useState(""); const [branches, setBranches] = useState(null); + const [worktrees, setWorktrees] = useState([]); + // Two ways to start a worktree lane, and git's own rule decides which is + // possible: "new" forks a branch off `base`, "existing" checks out a branch + // that is already there — which git allows exactly once across all of a + // repo's worktrees, hence the busy markers below. + const [branchMode, setBranchMode] = useState<"new" | "existing">("new"); const [base, setBase] = useState(""); - const [pipeline, setPipeline] = useState("default"); // "native" is the built-in profile, not a null state — see server/lib/ai-routers.js. const [router, setRouter] = useState("native"); const [routers, setRouters] = useState([]); const [routersLoaded, setRoutersLoaded] = useState(false); - const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]); const [branchesError, setBranchesError] = useState(null); const [tool, setTool] = useState("claude"); const [busy, setBusy] = useState(false); @@ -117,8 +138,9 @@ export function AddLaneModal({ setTitle(""); setBranch(""); setBranches(null); + setWorktrees([]); + setBranchMode("new"); setBase(""); - setPipeline("default"); setRouter("native"); setRoutersLoaded(false); setTool("claude"); @@ -128,23 +150,23 @@ export function AddLaneModal({ setSetupResult(null); }; - // The template a lane is created with is the ONLY chance to get it right - // from here: nothing else in the UI can change it afterwards, so a lane - // silently born on `default` renders an 8-node map for a 16-node workflow. - // Fetched on open (templates are file-backed and can change between opens). + // A prefill only ever seeds the fields ON OPEN: the user may then change + // anything, and re-applying it on every render would fight them for the box. + useEffect(() => { + if (!open || !prefill) return; + if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo); + if (prefill.branch) { + setBranch(prefill.branch); + setBranchMode("existing"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // A session that boots on the vendor's API keeps talking to it for its whole + // life, so the router has to be picked here, before the lane exists. useEffect(() => { if (!open) return; let cancelled = false; - api.lanes - .pipelines() - .then((r) => { - if (!cancelled) setPipelines(r.pipelines); - }) - .catch(() => { - // Quiet: the select just falls back to the single `default` option - // below, and the lane still gets created. - if (!cancelled) setPipelines([]); - }); api.aiRouters .list() .then((r) => { @@ -180,6 +202,7 @@ export function AddLaneModal({ const path = sourceRepo.trim(); if (!path) { setBranches(null); + setWorktrees([]); setBase(""); setBranchesError(null); return; @@ -190,6 +213,7 @@ export function AddLaneModal({ const r = await api.lanes.branches(path); if (lookedUpFor.current !== path) return; // a newer path superseded this one setBranches(r.branches); + setWorktrees(r.worktrees || []); setBase(r.current || r.branches[0] || ""); setBranchesError(null); } catch { @@ -197,6 +221,7 @@ export function AddLaneModal({ // Not yet a valid repo path (still being typed, or genuinely wrong) - // quiet by design, the same way CwdAutocomplete never errors either. setBranches(null); + setWorktrees([]); setBase(""); setBranchesError(t("addLaneNotARepo")); } @@ -220,7 +245,6 @@ export function AddLaneModal({ const result = await api.lanes.ensure({ cwd: repo, title: name || undefined, - pipeline, tool, }); // Report the lane the INSTANT it exists server-side. Everything after @@ -250,14 +274,17 @@ export function AddLaneModal({ return; } - if (!branches || !name || !branch.trim()) return; + // A blank branch in "new" mode is legal and deliberate: the server names + // it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews. + if (!branches || !name || (branchMode === "existing" && !branch.trim())) return; try { const result = await api.lanes.worktree({ sourceRepo: repo, title: name, - base: base || undefined, - branch: branch.trim(), - pipeline, + // An existing branch is checked out as it stands; `base` only means + // something when a branch is being forked. + base: branchMode === "new" ? base || undefined : undefined, + branch: branch.trim() || undefined, tool, }); onAdded(result.lane); @@ -348,7 +375,16 @@ export function AddLaneModal({ const done = !!setupResult; const disabled = !done && - (!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim()))); + (!sourceRepo.trim() || + (mode === "worktree" && + (!title.trim() || !branches || (branchMode === "existing" && !branch.trim())))); + + /** branch name -> the worktree directory already holding it. git allows one + * checkout per branch, so these are the branches this form cannot use. */ + const busyBranches = new Map( + worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path]) + ); + const freeBranches = (branches || []).filter((b) => !busyBranches.has(b)); return (
+ {/* What this repo already has checked out. Without it the form asked + for a branch in the dark: the one fact that decides whether a + branch can be used at all is which worktree is holding it. */} + {mode === "worktree" && worktrees.length > 0 && ( +
+

+ {t("addLaneWorktreesTitle", { count: worktrees.length })} +

+
    + {worktrees.slice(0, 8).map((w) => ( +
  • + + ⑂ {w.branch || t("addLaneWorktreeDetached")} + + + {w.path} + +
  • + ))} + {worktrees.length > 8 && ( +
  • + {t("addLaneWorktreesMore", { count: worktrees.length - 8 })} +
  • + )} +
+
+ )} +
-
- - -

{t("addLanePipelineHint")}

-
- -
- {t("addLaneRouterLabel")} -
- {( - [ - { value: "native", label: t("addLaneRouterNative") }, - { value: "router", label: t("addLaneRouterCustom") }, - ] as const - ).map((option) => { - const active = option.value === "native" ? router === "native" : router !== "native"; - // Clicking "AI Router" before the list lands would resolve back to - // Native and read as a dead control. - const disabled = option.value === "router" && !routersLoaded; - return ( - - ); - })} -
- - {router !== "native" && ( - - )} -

- {!routersLoaded - ? t("addLaneRouterLoading") - : routers.some((r) => !r.builtin) - ? t("addLaneRouterHint") - : t("addLaneRouterEmptyHint")} -

-
- - {mode === "worktree" && branches && ( -
- - {branches.length === 0 ? ( -

{t("addLaneNoBranches")}

- ) : ( - - )} -
- )} {mode === "worktree" && branchesError && !branches && (

{branchesError}

)} {mode === "worktree" && branches && ( -
- - setBranch(e.target.value)} - placeholder={t("addLaneBranchPlaceholder")} - className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none" - /> -

{t("addLaneBranchHint")}

+
+
+ { + setBranchMode("new"); + setBranch(""); + }} + /> + { + setBranchMode("existing"); + // Land on something usable rather than on an empty select + // that looks like a choice was already made. + setBranch((current) => + freeBranches.includes(current) ? current : freeBranches[0] || "" + ); + }} + /> + {/* The third answer to "which branch": none. A small or personal + project usually wants the lane to BE the repo, working on + whatever is checked out (`main`) — which is exactly Repo + mode, so this jumps there rather than inventing a second way + to do it. git cannot check `main` out in a second worktree + while the repo itself holds it, so a worktree lane could not + offer this even if it wanted to. */} + setMode("repo")} + /> +
+ + {branchMode === "new" ? ( + <> +
+ + {branches.length === 0 ? ( +

{t("addLaneNoBranches")}

+ ) : ( + + )} +
+
+ + setBranch(e.target.value)} + // The placeholder is what the SERVER will name the branch + // if this is left empty, so the default is visible without + // the form having to duplicate LANE_BRANCH_PREFIX. + placeholder={ + title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder") + } + className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> +

{t("addLaneBranchHintAuto")}

+
+ + ) : ( +
+ + {freeBranches.length === 0 ? ( +

{t("addLaneNoFreeBranches")}

+ ) : ( + + )} +

{t("addLaneBranchExistingHint")}

+
+ )}
)} + {/* The router is the one per-lane extra this form still asks for, so it + sits in the form itself — a disclosure around a single field only + hid it. The pipeline picker is gone entirely: every lane is created + on `default` (the server's own default) and the template is changed + from the lane's own header afterwards. */} +
+
+
+ {t("addLaneRouterLabel")} +
+ {( + [ + { value: "native", label: t("addLaneRouterNative") }, + { value: "router", label: t("addLaneRouterCustom") }, + ] as const + ).map((option) => { + const active = + option.value === "native" ? router === "native" : router !== "native"; + // Clicking "AI Router" before the list lands would resolve back to + // Native and read as a dead control. + const disabled = option.value === "router" && !routersLoaded; + return ( + + ); + })} +
+ + {router !== "native" && ( + + )} +

+ {!routersLoaded + ? t("addLaneRouterLoading") + : routers.some((r) => !r.builtin) + ? t("addLaneRouterHint") + : t("addLaneRouterEmptyHint")} +

+
+
+
+ {setupResult && (

{t("addLaneSetupTitle")}

diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 5ce44175..20803f4e 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -24,6 +24,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; +import { LaneGitBar } from "./LaneGitBar"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { keepIfSame } from "../../lib/keepIfSame"; @@ -49,8 +50,11 @@ const LOCKS_REFRESH_MS = 30_000; * polled lane list. Absent facts are not an error state: a lane may point at a * plain directory, and it renders as a card without a git row. */ -function useLaneGitFacts(laneId: number): LaneGitFacts | null { +function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] { const [facts, setFacts] = useState(null); + // Bumped by the git action bar: a commit or a pull changes every fact on + // this row, and waiting out the 30s poll makes the button look inert. + const [nonce, setNonce] = useState(0); useEffect(() => { let alive = true; @@ -72,9 +76,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null { alive = false; clearInterval(timer); }; - }, [laneId]); + }, [laneId, nonce]); - return facts; + return [facts, () => setNonce((n) => n + 1)]; } /** @@ -609,6 +613,7 @@ export default function LaneCard({ onAction, childWorktrees, onSelectLane, + onNewLaneFromRepo, }: { lane: Lane; onAction: (action: string, body?: Record) => void; @@ -618,6 +623,9 @@ export default function LaneCard({ childWorktrees?: Lane[]; /** Jumps the Workspace page's selection to another lane's card. */ onSelectLane?: (id: number) => void; + /** Opens the Add-lane flow prefilled with this lane's repo, for a sibling + * worktree on `branch`. Omitted where that flow is not reachable. */ + onNewLaneFromRepo?: (repo: string, branch?: string) => void; }) { const { t } = useTranslation(["lanes"]); const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null); @@ -639,7 +647,7 @@ export default function LaneCard({ filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150); }, []); useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []); - const git = useLaneGitFacts(lane.id); + const [git, refreshGit] = useLaneGitFacts(lane.id); const runtime = useLaneRuntime(lane.id, runtimeBump); const locks = useLaneLocks(lane.slot); @@ -809,6 +817,21 @@ export default function LaneCard({ )}
)} + {git?.available && ( + onNewLaneFromRepo(lane.source_repo || lane.cwd, branch) + : undefined + } + /> + )} {/* The lane's own recorded branch, shown only when git could not be read — otherwise it duplicates the live branch above. */} {!git?.available && lane.branch && ( diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx new file mode 100644 index 00000000..6781302c --- /dev/null +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -0,0 +1,905 @@ +/** + * @file The Changes panel: the lane's code beside its console, in the five + * shapes a review actually needs — + * + * overview — every changed file with its +/- counts and its stage/discard + * actions; click one to open it + * review — every file's full diff, stacked, for reading a whole change-set + * file — one file, as a diff or as its whole content (issue #35) + * history — recent commits; opening one shows that commit's diff + * browse — quick-open by path, or the repo's tracked file tree + * + * and three ranges: `work` (uncommitted, what the agent has touched right + * now), `branch` (`base...HEAD`, what this lane adds on top of its base) and + * `commit` (one commit, reached from history). + * + * Only the overview polls. Review, file, history and browse are a deliberate + * read of a frozen moment — re-fetching under the reader's scroll position is + * worse than a slightly stale view, and those are the expensive shapes. + * + * Rendering lives in LaneDiffView.tsx. Every write goes through + * `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here + * before the server is told `confirm: true` — it is the one action in this + * panel git cannot undo. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, + Columns2, + FileText, + FoldVertical, + Folder, + Minus, + Plus, + RefreshCw, + Rows3, + Trash2, + UnfoldVertical, +} from "lucide-react"; +import { api } from "../../lib/api"; +import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView"; +import type { + LaneChangeFile, + LaneChangeRange, + LaneChanges, + LaneCommit, + LaneTreeEntry, +} from "../../lib/types"; + +/** How often the overview re-reads. Summary only — no patch text. */ +const OVERVIEW_REFRESH_MS = 15_000; +/** Context steps the expand button walks through, then the whole file. */ +const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000]; + +const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!; + +type Mode = "overview" | "review" | "file" | "history" | "browse"; + +/** Colour per porcelain status letter, matching LaneCard's file panel. */ +function statusClass(status: string, untracked: boolean) { + if (untracked) return "text-status-success"; + const code = status.trim()[0]; + if (code === "A") return "text-status-success"; + if (code === "D") return "text-status-danger"; + if (code === "R") return "text-accent"; + return "text-status-warning"; +} + +/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file + * has no line delta at all, so it says `bin` rather than leaving the row's + * right edge blank, which reads as "changed by nothing". */ +function Delta({ + added, + deleted, + binary, +}: { + added: number | null; + deleted: number | null; + binary?: boolean; +}) { + const { t } = useTranslation(["lanes"]); + if (binary) + return {t("changes.bin")}; + return ( + + {added ? +{added} : null} + {added && deleted ? " " : null} + {deleted ? −{deleted} : null} + + ); +} + +/** + * One folder level of the browse tree, rendered in place and recursing into + * every expanded child — so opening a nested file no longer means navigating + * away from its siblings. + */ +function TreeNodes({ + path, + depth, + children, + expanded, + onToggleDir, + onOpenFile, +}: { + path: string; + depth: number; + children: Record; + expanded: Set; + onToggleDir: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const entries = children[path]; + if (!entries) return null; + return ( + <> + {entries.map((e) => ( +
  • + + {e.dir && expanded.has(e.path) && ( +
      + +
    + )} +
  • + ))} + + ); +} + +function IconButton({ + label, + onClick, + disabled, + danger, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function LaneChangesPanel({ laneId }: { laneId: number }) { + const { t } = useTranslation(["lanes"]); + const [range, setRange] = useState("work"); + const [mode, setMode] = useState("overview"); + const [openFile, setOpenFile] = useState(null); + const [sha, setSha] = useState(null); + const [context, setContext] = useState(CONTEXT_STEPS[0]); + const [layout, setLayout] = useState("unified"); + const [whole, setWhole] = useState(false); + const [data, setData] = useState(null); + 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>({}); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState(null); + const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>( + null + ); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [confirmDiscard, setConfirmDiscard] = useState(null); + // Files folded shut in review mode. A change-set is read file by file, and + // a file already read is in the way of the next one — so the set is what is + // COLLAPSED, and a file arriving later is open by default. + const [collapsed, setCollapsed] = useState>(new Set()); + // Where opening a file came FROM: its mode and how far the list was scrolled. + // Reviewing a change-set is a walk down a long list, and landing back at the + // top of it after every file is the whole cost of opening one. + const scroller = useRef(null); + const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null); + const pendingScroll = useRef(null); + const [message, setMessage] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const result = await api.lanes.changes(laneId, { + range, + ...(range === "commit" && sha ? { sha } : {}), + ...(mode === "file" && openFile ? { path: openFile, context } : {}), + ...(mode === "review" ? { full: true } : {}), + }); + setData(result); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [laneId, range, sha, mode, openFile, context]); + + useEffect(() => { + if (mode === "history" || mode === "browse") return; + void load(); + // Only the cheap shape polls; see the file header. + if (mode !== "overview") return; + const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS); + return () => window.clearInterval(timer); + }, [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. + useEffect(() => { + if (mode !== "history") return; + void api.lanes + .gitLog(laneId, { limit: 50 }) + .then((r) => setCommits(r.commits)) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [mode, laneId]); + + useEffect(() => { + if (mode !== "browse" || query.trim() || "" in treeChildren) return; + void api.lanes + .gitTree(laneId, "") + .then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // `treeChildren` is the cache this fills — reading it as a dep would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, laneId, query]); + + // A folder's children are fetched lazily, the first time it is expanded. + const toggleDir = (path: string) => { + setExpandedDirs((prev) => { + const next = new Set(prev); + if (!next.delete(path)) next.add(path); + return next; + }); + if (path in treeChildren) return; + void api.lanes + .gitTree(laneId, path) + .then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }; + + const [treeBusy, setTreeBusy] = useState(false); + + // Fetches whatever level isn't cached yet, depth-first, so "expand all" + // works even on a tree nothing has been opened in. + const expandAllDirs = async () => { + setTreeBusy(true); + const cache = { ...treeChildren }; + const toExpand = new Set(expandedDirs); + const walk = async (path: string) => { + let entries = cache[path]; + if (!entries) { + entries = (await api.lanes.gitTree(laneId, path)).entries; + cache[path] = entries; + } + const dirs = entries.filter((e) => e.dir); + for (const d of dirs) toExpand.add(d.path); + await Promise.all(dirs.map((d) => walk(d.path))); + }; + try { + await walk(""); + setTreeChildren(cache); + setExpandedDirs(toExpand); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setTreeBusy(false); + } + }; + + useEffect(() => { + const q = query.trim(); + if (mode !== "browse" || !q) { + setHits(null); + return; + } + const timer = window.setTimeout(() => { + void api.lanes + .gitLs(laneId, q) + .then((r) => setHits(r.paths)) + .catch(() => setHits([])); + }, 150); + return () => window.clearTimeout(timer); + }, [mode, laneId, query]); + + // The whole-file read is a different endpoint from the diff, so it is its + // own fetch — and only while the viewer is actually showing it. + useEffect(() => { + if (mode !== "file" || !whole || !openFile) return; + void api.lanes + .gitFile(laneId, openFile, range === "commit" && sha ? sha : "work") + .then((r) => + setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null) + ) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [mode, whole, openFile, laneId, range, sha]); + + // A lane switch must not leave the previous lane's file open — its path + // almost certainly does not exist in the new one. + useEffect(() => { + setMode("overview"); + setOpenFile(null); + setSha(null); + setRange("work"); + setTreeChildren({}); + setExpandedDirs(new Set()); + setCommits(null); + setCollapsed(new Set()); + returnTo.current = null; + pendingScroll.current = null; + }, [laneId]); + + // Restoring has to wait for the list to be TALL enough again: leaving a file + // re-fetches the mode it came from, so the first render back is an empty + // box that would swallow the scroll. + useLayoutEffect(() => { + const el = scroller.current; + const want = pendingScroll.current; + if (mode === "file" || want == null || !el) return; + if (el.scrollHeight - el.clientHeight < want) return; + el.scrollTop = want; + pendingScroll.current = null; + }, [mode, data]); + + const show = (path: string) => { + // Opening a file FROM a file (the review list is gone by then) must not + // overwrite the list position that is still waiting to be restored. + if (mode !== "file") { + returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 }; + } + setOpenFile(path); + setContext(CONTEXT_STEPS[0]); + setWhole(false); + setText(null); + setMode("file"); + }; + + const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => { + setBusy(true); + setError(null); + try { + await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) }); + setConfirmDiscard(null); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const commitStaged = async () => { + setBusy(true); + setError(null); + try { + // `only: "staged"` — the whole point of staging file by file is that the + // commit takes the index as it stands, with no `add -A` sweeping the + // rest of the tree in behind it. + await api.lanes.gitAction(laneId, "commit", { message: message.trim(), only: "staged" }); + setMessage(""); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const toggleCollapsed = (path: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (!next.delete(path)) next.add(path); + return next; + }); + + const files = data?.available ? data.files : []; + const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path)); + const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0; + const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; + + const Row = ({ f }: { f: LaneChangeFile }) => ( +
  • + + {range === "work" && ( + + {f.staged ? ( + void act("unstage", f.path)} + > + + + ) : null} + {f.unstaged || f.untracked ? ( + void act("stage", f.path)} + > + + + ) : null} + { + // Two clicks, always: the first arms the row, the second is the + // only one that reaches the server (which itself refuses the + // call without `confirm: true`). + if (confirmDiscard === f.path) void act("discard", f.path, true); + else setConfirmDiscard(f.path); + }} + > + + + + )} +
  • + ); + + return ( +
    + {/* Two rows on purpose: WHERE you are (mode, and the refresh that always + applies) on top, HOW you are looking at it (range, layout, context, + whole file) underneath. One row wrapped mid-group as soon as the + panel was narrow, which is every panel beside a console. */} +
    +
    + {mode === "file" ? ( + + ) : ( +
    + {(["browse", "overview", "review", "history"] as const).map((m) => ( + + ))} +
    + )} + + +
    + + {mode !== "history" && mode !== "browse" && ( +
    +
    + {(["work", "branch"] as const).map((r) => ( + + ))} + {range === "commit" && sha && ( + + {sha.slice(0, 7)} + + )} +
    + + {(mode === "file" || mode === "review") && ( +
    + {mode === "review" && ( + + setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path))) + } + > + {allCollapsed ? ( + + ) : ( + + )} + + )} + setLayout(layout === "unified" ? "split" : "unified")} + > + {layout === "unified" ? ( + + ) : ( + + )} + + {mode === "file" && ( + <> + {/* Context walks BOTH ways: an expanded diff that cannot + be collapsed again leaves the reader re-opening the + file to get the tight view back. */} + + setContext( + [...CONTEXT_STEPS].reverse().find((step) => step < context) ?? + CONTEXT_STEPS[0] + ) + } + > + + + = LAST_CONTEXT} + onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)} + > + + + + )} +
    + )} + + {mode === "file" && ( + + )} +
    + )} +
    + + {error && ( +

    + {error} +

    + )} + + {/* In single-file mode the DIFF owns the scrolling, so its horizontal + bar sits at the bottom of the visible box instead of at the bottom of + a 2000-line container nobody has scrolled to yet. Every other mode is + a list, and a list scrolls here. */} +
    + {mode === "history" ? ( +
      + {(commits ?? []).map((c) => ( +
    • + +
    • + ))} + {commits && commits.length === 0 && ( +
    • {t("changes.noCommits")}
    • + )} +
    + ) : mode === "browse" ? ( +
    +
    + setQuery(e.target.value)} + placeholder={t("changes.quickOpen")} + aria-label={t("changes.quickOpen")} + className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" + /> + {!hits && ( + 0 + ? t("changes.collapseAllFolders") + : t("changes.expandAllFolders") + } + disabled={treeBusy} + onClick={() => + expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs() + } + > + {expandedDirs.size > 0 ? ( + + ) : ( + + )} + + )} +
    + {hits ? ( +
      + {hits.map((p) => ( +
    • + +
    • + ))} + {hits.length === 0 && ( +
    • {t("changes.noMatches")}
    • + )} +
    + ) : ( +
      + { + show(p); + setWhole(true); + }} + /> +
    + )} +
    + ) : !data ? ( +

    {t("changes.loading")}

    + ) : !data.available ? ( +

    {t("changes.unavailable")}

    + ) : files.length === 0 ? ( +

    {t(`changes.empty.${range}`)}

    + ) : mode === "overview" ? ( + <> +
      + {files.map((f) => ( + + ))} +
    + {stagedCount > 0 && ( +
    + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && message.trim() && !busy) void commitStaged(); + }} + placeholder={t("changes.commitPlaceholder")} + aria-label={t("changes.commitPlaceholder")} + className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> + +
    + )} + + ) : mode === "file" ? ( + current || (whole && text) ? ( +
    +
    + + + {openFile} + + {current && ( + + )} +
    + {whole ? ( + text ? ( + + ) : ( +

    {t("changes.loading")}

    + ) + ) : current ? ( + + ) : null} +
    + ) : ( +

    {t("changes.fileGone")}

    + ) + ) : ( +
    + {files.map((f) => { + const shut = collapsed.has(f.path); + return ( +
    + {/* The whole header folds the file; only the PATH opens it. + A header that opened the file on any click made folding a + target-practice exercise against one chevron. */} +
    toggleCollapsed(f.path)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleCollapsed(f.path); + } + }} + aria-expanded={!shut} + aria-label={ + shut + ? t("changes.expandFile", { path: f.path }) + : t("changes.collapseFile", { path: f.path }) + } + className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${ + shut ? "" : "border-b border-border-light" + }`} + > + + + {f.untracked ? "A" : f.status.trim()[0] || "M"} + + + +
    + {!shut && } +
    + ); + })} +
    + )} + + {data?.available && data.truncated && mode !== "history" && mode !== "browse" && ( +

    {t("changes.truncated")}

    + )} +
    +
    + ); +} + +export default LaneChangesPanel; diff --git a/client/src/components/lanes/LaneDiffView.tsx b/client/src/components/lanes/LaneDiffView.tsx new file mode 100644 index 00000000..20c982ba --- /dev/null +++ b/client/src/components/lanes/LaneDiffView.tsx @@ -0,0 +1,317 @@ +/** + * @file The diff renderer behind the Changes panel (issue #35): one file's + * hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted, + * with the changed span inside a modified line picked out. + * + * Three deliberate choices: + * - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation + * view already ships. No new dependency for a panel that renders a few + * hundred lines. + * - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is + * right for the case that matters (one edited span on a line), degrades to + * "the whole line changed" otherwise, and costs nothing. + * - Line pairing for split view happens here, on the rows the server already + * numbered. The server stays the single source of what changed; this file + * only decides how it looks. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { canonicalLang, highlight, tokenClass } from "../../lib/highlight"; +import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types"; + +export type DiffLayout = "unified" | "split"; + +/** + * How tall a diff box may be. `fill` (the single-file view) takes the height + * its parent gives it; otherwise — the review list, which stacks many files — + * each file is capped so its own horizontal scrollbar stays on screen. + */ +function boxHeight(fill: boolean) { + return fill ? "min-h-0 flex-1" : "max-h-[60vh]"; +} + +/** Language tag for a path, for the tokenizer. Unknown ones render plain. */ +function langFor(filePath: string) { + const ext = filePath.split(".").pop() || ""; + return canonicalLang(ext === filePath ? "plain" : ext); +} + +/** Tokenized line text. `emph` marks the span that actually changed. */ +function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) { + if (emph) { + const [from, to] = emph; + return ( + <> + + + + + + + ); + } + return ( + <> + {highlight(text, lang).map((tok, i) => ( + + {tok.text} + + ))} + + ); +} + +/** + * The changed span of a del/add pair, as `[start, end]` offsets into each + * side, or null when the lines share nothing worth pointing at. + */ +function emphasis( + del: string, + add: string +): { del: [number, number]; add: [number, number] } | null { + if (del === add) return null; + let start = 0; + const max = Math.min(del.length, add.length); + while (start < max && del[start] === add[start]) start += 1; + let end = 0; + while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1; + // Nothing in common at either edge: the whole line is the change, and + // highlighting all of it adds noise on top of the row tint. + if (start === 0 && end === 0) return null; + return { + del: [start, del.length - end], + add: [start, add.length - end], + }; +} + +/** Pair each run of removed lines with the run of added lines following it. */ +function pairs( + lines: LaneDiffLine[] +): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> { + const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = []; + for (let i = 0; i < lines.length; ) { + const line = lines[i]!; + if (line.type === "ctx") { + out.push({ left: line, right: line }); + i += 1; + continue; + } + const dels: LaneDiffLine[] = []; + const adds: LaneDiffLine[] = []; + while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!); + while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!); + for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) { + out.push({ left: dels[k] ?? null, right: adds[k] ?? null }); + } + } + return out; +} + +function Gutter({ n }: { n: number | null }) { + return ( + {n ?? ""} + ); +} + +/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */ +function Row({ tint, children }: { tint: string; children: React.ReactNode }) { + return
    {children}
    ; +} + +function rowTint(type: LaneDiffLine["type"] | null) { + if (type === "add") return "bg-status-success/10"; + if (type === "del") return "bg-status-danger/10"; + return ""; +} + +function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) { + const at = hunk.header.split("@@")[1]; + return ( +
    + {at ? `@@${at}@@` : hunk.header} + {hunk.context ? ` ${hunk.context}` : ""} +
    + ); +} + +function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) { + const emph = new Map(); + for (const { left, right } of pairs(hunk.lines)) { + if (!left || !right || left.type !== "del" || right.type !== "add") continue; + const e = emphasis(left.text, right.text); + if (!e) continue; + emph.set(hunk.lines.indexOf(left), e.del); + emph.set(hunk.lines.indexOf(right), e.add); + } + return ( +
    + + {hunk.lines.map((line, i) => ( + + + + + + {line.type === "add" ? "+" : line.type === "del" ? "-" : " "} + + + + + ))} +
    + ); +} + +/** One side of the split view: a hunk header, then that side's rows. */ +function SplitColumn({ + hunks, + lang, + side, +}: { + hunks: LaneDiffHunk[]; + lang: string; + side: "left" | "right"; +}) { + return ( + <> + {hunks.map((hunk, h) => ( +
    + + {pairs(hunk.lines).map(({ left, right }, i) => { + const e = + left && right && left.type === "del" && right.type === "add" + ? emphasis(left.text, right.text) + : null; + const line = side === "left" ? left : right; + return ( + + + + {line ? ( + + ) : null} + + + ); + })} +
    + ))} + + ); +} + +/** Both sides, each scrolling horizontally on its own but kept in step. */ +function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) { + const left = useRef(null); + const right = useRef(null); + + // Mirroring fires the other pane's own scroll handler, which mirrors back — + // the equality check is what ends that, so no re-entry flag is needed. + const sync = useCallback((from: "left" | "right") => { + const src = from === "left" ? left.current : right.current; + const dst = from === "left" ? right.current : left.current; + if (!src || !dst || dst.scrollLeft === src.scrollLeft) return; + dst.scrollLeft = src.scrollLeft; + }, []); + + return ( +
    +
    sync("left")} + className="w-1/2 min-w-0 overflow-auto" + > + +
    +
    sync("right")} + className="w-1/2 min-w-0 overflow-auto border-l border-border-light" + > + +
    +
    + ); +} + +/** One file's diff, in the chosen layout. */ +export function LaneDiffView({ + file, + layout, + fill = false, +}: { + file: LaneChangeFile; + layout: DiffLayout; + /** Take the parent's height (single-file view) instead of the 60vh cap. */ + fill?: boolean; +}) { + const { t } = useTranslation(["lanes"]); + const lang = langFor(file.path); + + if (file.binary) + return

    {t("changes.binary")}

    ; + if (!file.hunks || file.hunks.length === 0) + return

    {t("changes.noHunks")}

    ; + + return ( +
    + {layout === "split" ? ( + + ) : ( + file.hunks.map((hunk, i) => ( + + )) + )} + {file.truncated && ( +

    {t("changes.fileTruncated")}

    + )} +
    + ); +} + +/** A whole file's text (no diff), for the viewer's "read the file" mode. */ +export function LaneFileView({ + path, + lines, + truncated, + fill = false, +}: { + path: string; + lines: string[]; + truncated?: boolean; + /** Take the parent's height instead of the 60vh cap. */ + fill?: boolean; +}) { + const { t } = useTranslation(["lanes"]); + const lang = langFor(path); + return ( +
    + {lines.map((text, i) => ( + + + + + + + ))} + {truncated && ( +

    {t("changes.fileTruncated")}

    + )} +
    + ); +} + +export default LaneDiffView; diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx new file mode 100644 index 00000000..b3ef3f9c --- /dev/null +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -0,0 +1,352 @@ +/** + * @file The lane's git action row: ahead/behind against the upstream, and the + * writes `POST /api/lanes/:id/git/:action` offers that are not per-file — + * fetch, pull, push, commit (everything, or only what the Changes panel has + * staged), switch branch, and create/delete a branch. Per-file staging and + * discard live in the Changes panel, beside the file they act on. + * + * It renders beside the read-only git line in LaneCard rather than inside the + * ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these + * are the opposite — the ordinary moves a lane needs several times an hour. + * Nothing here decides what an action may do; the server does (pull is always + * --ff-only, switch refuses a dirty tree), and this only reports what it said. + * Failures print git's own text, because "! [rejected] non-fast-forward" tells + * the user more than any sentence this file could write. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowDown, + ArrowUp, + Check, + GitBranch, + Plus, + RefreshCw, + Trash2, + Upload, + X, +} from "lucide-react"; +import { api } from "../../lib/api"; +import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; + +type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete"; + +function Btn({ + label, + icon, + busy, + disabled, + title, + onClick, +}: { + label: string; + icon?: React.ReactNode; + busy?: boolean; + disabled?: boolean; + title?: string; + onClick: () => void; +}) { + return ( + + ); +} + +export function LaneGitBar({ + laneId, + git, + onChanged, + onNewLane, +}: { + laneId: number; + git: LaneGitFacts; + /** Called after any action that could have moved the working copy. */ + onChanged: () => void; + /** Open the Add-lane flow for a sibling worktree of this repo, optionally on + * a branch this bar just created. Omitted where no such flow is reachable. */ + onNewLane?: (branch?: string) => void; +}) { + const { t } = useTranslation(["lanes"]); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [committing, setCommitting] = useState(false); + const [message, setMessage] = useState(""); + const [switching, setSwitching] = useState(false); + const [branches, setBranches] = useState(null); + const [worktrees, setWorktrees] = useState([]); + const [picked, setPicked] = useState(""); + const [newBranch, setNewBranch] = useState(""); + + if (!git.available) return null; + const dirty = git.dirty + git.untracked; + + const run = async ( + action: Action, + body?: { message?: string; only?: "staged"; branch?: string } + ) => { + setBusy(action); + setError(null); + try { + await api.lanes.gitAction(laneId, action, body || {}); + setCommitting(false); + setMessage(""); + // The branch list this bar is showing is stale the moment a branch is + // created, deleted or switched to, so it is re-read rather than patched. + if (action === "switch") setSwitching(false); + else if (action.startsWith("branch-")) { + setNewBranch(""); + setPicked(""); + setBranches(null); + await refreshBranches(); + } + onChanged(); + } catch (err) { + // The server appends git's own stderr to the message, so this is the + // rejection text verbatim — hence the
     below rather than a line.
    +      setError(err instanceof Error ? err.message : String(err));
    +    } finally {
    +      setBusy(null);
    +    }
    +  };
    +
    +  const refreshBranches = async () => {
    +    try {
    +      const r = await api.lanes.gitBranches(laneId);
    +      setBranches(r.branches);
    +      setWorktrees(r.worktrees || []);
    +    } catch {
    +      setBranches([]);
    +    }
    +  };
    +
    +  const openSwitch = async () => {
    +    setSwitching((v) => !v);
    +    setError(null);
    +    if (branches) return;
    +    await refreshBranches();
    +  };
    +
    +  const heldBy = new Map(
    +    worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
    +  );
    +
    +  return (
    +    
    +
    + {/* Ahead/behind first: it is the fact that decides which of the + buttons beside it the user actually wants. */} + {git.upstream ? ( + + {(git.ahead ?? 0) > 0 && ( + + + {git.ahead} + + )} + {(git.behind ?? 0) > 0 && ( + + + {git.behind} + + )} + {!git.ahead && !git.behind && {t("gitBar.inSync")}} + + ) : ( + git.hasRemote && ( + {t("gitBar.noUpstream")} + ) + )} + + } + busy={busy === "fetch"} + disabled={!git.hasRemote || !!busy} + title={t("gitBar.fetchHint")} + onClick={() => run("fetch")} + /> + } + busy={busy === "pull"} + disabled={!git.hasRemote || !!busy} + title={t("gitBar.pullHint")} + onClick={() => run("pull")} + /> + } + busy={busy === "push"} + disabled={!git.hasRemote || !!busy} + title={t("gitBar.pushHint")} + onClick={() => run("push")} + /> + } + disabled={dirty === 0 || !!busy} + title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")} + onClick={() => { + setCommitting((v) => !v); + setError(null); + }} + /> + } + disabled={!!busy} + // A dirty tree is refused server-side; saying so before the click + // beats a red box after it. + title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")} + onClick={openSwitch} + /> +
    + + {committing && ( +
    + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && message.trim()) void run("commit", { message }); + if (e.key === "Escape") setCommitting(false); + }} + placeholder={t("gitBar.commitPlaceholder")} + aria-label={t("gitBar.commitPlaceholder")} + className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> + void run("commit", { message })} + /> + {/* The Changes panel's per-file staging only means something if a + commit can take the index as it stands. The server answers + ENOCHANGES when nothing is staged, and that text lands below. */} + void run("commit", { message, only: "staged" })} + /> + +
    + )} + + {switching && ( +
    + + 0} + title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")} + onClick={() => void run("switch", { branch: picked })} + /> + {onNewLane && ( + onNewLane(picked)} + /> + )} + } + busy={busy === "branch-delete"} + disabled={!picked || picked === git.branch || !!heldBy.get(picked)} + title={t("gitBar.branchDeleteHint")} + onClick={() => void run("branch-delete", { branch: picked })} + /> + {dirty > 0 && ( + {t("gitBar.switchDirty")} + )} +
    + )} + + {switching && ( +
    + setNewBranch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newBranch.trim()) + void run("branch-create", { branch: newBranch.trim() }); + }} + placeholder={t("gitBar.branchNewPlaceholder")} + aria-label={t("gitBar.branchNewPlaceholder")} + className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> + } + busy={busy === "branch-create"} + disabled={!newBranch.trim()} + // Creating does not switch: the point is usually a sibling lane, + // and moving this pane's HEAD under a working agent is not. + title={t("gitBar.branchCreateHint")} + onClick={() => void run("branch-create", { branch: newBranch.trim() })} + /> +
    + )} + + {error && ( +
    +          {error}
    +        
    + )} +
    + ); +} + +export default LaneGitBar; diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 723de29e..a891aab2 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -109,18 +109,6 @@ async function fillWorktreeForm( beforeEach(() => { vi.mocked(api.lanes.branches).mockReset(); - vi.mocked(api.lanes.pipelines) - .mockReset() - .mockResolvedValue({ - pipelines: [ - { id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) }, - { - id: "ship-feature", - name: "Ship feature (lane pipeline)", - nodes: new Array(16).fill({ id: "n" }), - }, - ], - }); vi.mocked(api.lanes.worktree).mockReset(); vi.mocked(api.lanes.ensure).mockReset(); vi.mocked(api.lanes.browse).mockReset(); @@ -181,8 +169,101 @@ function savedRouter(over: Record = {}) { }; } +describe("AddLaneModal — picking an existing branch (issue #34)", () => { + const withWorktrees = { + branches: ["main", "feat/other", "feat/held"], + current: "main", + worktrees: [ + { path: "/Users/tester/projects/repo", branch: "main" }, + { path: "/lanes/repo__held", branch: "feat/held" }, + ], + }; + + it("lists the repo's existing worktrees once the path resolves", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + + const panel = await screen.findByTestId("add-lane-worktrees"); + expect(panel).toHaveTextContent("/lanes/repo__held"); + expect(panel).toHaveTextContent("feat/held"); + }); + + it("checks an existing branch out, sending no base", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees); + vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "Reuse a branch"); + + await user.click(screen.getByRole("button", { name: "Existing branch" })); + const select = await screen.findByLabelText("Branch to check out"); + await user.selectOptions(select, "feat/other"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + await waitFor(() => + expect(api.lanes.worktree).toHaveBeenCalledWith( + expect.objectContaining({ branch: "feat/other", base: undefined }) + ) + ); + }); + + it("greys out a branch another worktree already holds", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.click(screen.getByRole("button", { name: "Existing branch" })); + + const held = await screen.findByRole("option", { name: /feat\/held/ }); + expect(held).toBeDisabled(); + expect(held).toHaveTextContent("/lanes/repo__held"); + expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled(); + }); + + it("lets the server name a new branch when the field is left empty", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees); + vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "Auto named"); + // The placeholder previews the name the server will pick. + expect(screen.getByLabelText("New branch name")).toHaveAttribute( + "placeholder", + "feat/auto-named" + ); + + await user.click(screen.getByRole("button", { name: "Add lane" })); + await waitFor(() => + expect(api.lanes.worktree).toHaveBeenCalledWith( + expect.objectContaining({ branch: undefined, base: "main" }) + ) + ); + }); +}); + describe("AddLaneModal — worktree mode (default)", () => { - it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => { + // The branch NAME is no longer part of this list: an empty one means "let + // the server name it" (issue #34). + it("disables confirm until a repo, a title and a resolved branch list are all present", () => { renderModal(); expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); }); @@ -191,6 +272,7 @@ describe("AddLaneModal — worktree mode (default)", () => { vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main", "feat/other"], current: "main", + worktrees: [], }); renderModal(); const user = userEvent.setup(); @@ -223,7 +305,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("submits through the worktree provisioning endpoint, with the typed branch name", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); const onAdded = vi.fn(); const onClose = vi.fn(); @@ -239,7 +325,6 @@ describe("AddLaneModal — worktree mode (default)", () => { title: "New feature", base: "main", branch: "feat/new-feature", - pipeline: "default", tool: "claude", }); }); @@ -253,7 +338,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("shows a server error and leaves the modal open instead of closing silently", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION")); const onClose = vi.fn(); renderModal({ onClose }); @@ -272,7 +361,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane, }); @@ -291,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("still calls onAdded even when every setup call fails", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane, }); @@ -311,7 +408,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, }); @@ -334,7 +435,11 @@ describe("AddLaneModal — worktree mode (default)", () => { }); it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => { - vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main"], + current: "main", + worktrees: [], + }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane, }); @@ -397,7 +502,6 @@ describe("AddLaneModal — repo mode (adopt)", () => { expect(api.lanes.ensure).toHaveBeenCalledWith({ cwd: "/Users/tester/projects/repo", title: "main repo", - pipeline: "default", tool: "claude", }) ); @@ -591,50 +695,6 @@ describe("AddLaneModal — a router profile that disappeared", () => { }); }); -describe("AddLaneModal — pipeline template", () => { - it("offers every template the server reports and sends the chosen one when adopting a repo", async () => { - // Creation is the ONLY point the UI can set a template, so a lane born on - // `default` renders 8 nodes for a 16-node workflow with no way back from - // any screen. - vi.mocked(api.lanes.ensure).mockResolvedValue({ - lane: laneFixture({ status: "idle" }), - created: true, - }); - renderModal(); - const user = userEvent.setup(); - - await user.click(screen.getByRole("button", { name: "Repo" })); - const select = await screen.findByLabelText("Pipeline template"); - await waitFor(() => - expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument() - ); - expect( - screen.getByRole("option", { name: /Default feature pipeline \(8\)/ }) - ).toBeInTheDocument(); - - const repoField = screen.getByLabelText("Directory"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await user.selectOptions(select, "ship-feature"); - await user.click(screen.getByRole("button", { name: "Add lane" })); - - await waitFor(() => - expect(api.lanes.ensure).toHaveBeenCalledWith( - expect.objectContaining({ pipeline: "ship-feature" }) - ) - ); - }); - - it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => { - vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline")); - renderModal(); - - const select = await screen.findByLabelText("Pipeline template"); - await waitFor(() => expect(select).toHaveValue("default")); - expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument(); - }); -}); - describe("AddLaneModal — tool selection", () => { it("sends the picked tool when adopting a repo", async () => { vi.mocked(api.lanes.ensure).mockResolvedValue({ @@ -679,3 +739,41 @@ describe("AddLaneModal — tool selection", () => { ); }); }); + +describe("AddLaneModal — working straight on main (no worktree)", () => { + beforeEach(() => { + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["development", "main"], + current: "development", + worktrees: [], + }); + }); + + it("jumps to Repo mode from the branch choice", async () => { + const user = userEvent.setup(); + renderModal(); + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByTestId("add-lane-branch-section"); + + await user.click(screen.getByRole("button", { name: "Current branch" })); + + // Repo mode adopts the directory as it stands — no branch section left to + // answer, because there is no fork to configure. + expect(screen.queryByTestId("add-lane-branch-section")).toBeNull(); + expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("prefills the repo and branch it was opened with", async () => { + renderModal({ + prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" }, + }); + expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument(); + expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo"); + expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); +}); diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 2f232fc5..1beff8fe 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -216,6 +216,12 @@ describe("LaneCard git block", () => { { path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 }, { path: "scratch.txt", status: "??", added: null, deleted: null }, ], + // Tracking facts ride along on the same endpoint; a branch that has never + // been pushed reports nulls rather than zeros. + hasRemote: true, + upstream: null, + ahead: null, + behind: null, }; it("renders the live branch, head, subject and uncommitted counts", async () => { diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx new file mode 100644 index 00000000..c366fd13 --- /dev/null +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -0,0 +1,540 @@ +/** + * @file LaneChangesPanel.test.tsx + * @description Pins the Changes panel's five modes and its three ranges: the + * overview lists files and nothing else, clicking one opens that file's diff + * (and asks the server for THAT path), Review asks for every file's hunks at + * once, the range toggle changes which comparison is requested, History opens + * one commit, and Files quick-opens a whole file. Also pins the per-file git + * writes — including discard needing two clicks before the server hears + * `confirm` — and the degrade for a cwd git cannot read. + * @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 { LaneChangesPanel } from "../LaneChangesPanel"; +import { api } from "../../../lib/api"; +import type { LaneChanges } from "../../../lib/types"; + +vi.mock("../../../lib/api", () => ({ + api: { + lanes: { + changes: vi.fn(), + gitAction: vi.fn(), + gitLog: vi.fn(), + gitFile: vi.fn(), + gitLs: vi.fn(), + gitTree: vi.fn(), + }, + }, +})); + +const HUNK = { + header: "@@ -1,2 +1,3 @@", + context: "", + oldStart: 1, + newStart: 1, + lines: [ + { type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" }, + { type: "del" as const, oldLine: 2, newLine: null, text: "old line" }, + { type: "add" as const, oldLine: null, newLine: 2, text: "new line" }, + ], +}; + +function changes(over: Partial> = {}): LaneChanges { + return { + available: true, + range: "work", + base: "development", + baseRef: "origin/development", + sha: null, + context: 3, + truncated: false, + files: [ + { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + unstaged: true, + added: 12, + deleted: 3, + binary: false, + }, + { path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false }, + { + path: "docs/API.md", + status: "M ", + untracked: false, + staged: true, + added: 2, + deleted: 1, + binary: false, + }, + ], + ...over, + }; +} + +beforeEach(() => { + vi.mocked(api.lanes.changes).mockReset(); + vi.mocked(api.lanes.changes).mockResolvedValue(changes()); + vi.mocked(api.lanes.gitAction).mockReset(); + vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" }); + vi.mocked(api.lanes.gitLog).mockResolvedValue({ + available: true, + commits: [ + { + sha: "a".repeat(40), + short: "aaaaaaa", + subject: "feat: the commit", + author: "t", + date: "2026-09-22T01:00:00Z", + refs: ["HEAD -> feat/thing"], + }, + ], + }); + vi.mocked(api.lanes.gitLs).mockResolvedValue({ + available: true, + paths: ["src/main.ts"], + truncated: false, + total: 1, + }); + vi.mocked(api.lanes.gitTree).mockResolvedValue({ + available: true, + path: "", + entries: [{ name: "src", path: "src", dir: true }], + truncated: false, + }); + vi.mocked(api.lanes.gitFile).mockResolvedValue({ + available: true, + path: "src/main.ts", + rev: "work", + binary: false, + truncated: false, + lines: ["const whole = 1;"], + }); +}); + +describe("LaneChangesPanel", () => { + it("opens on the overview: a summary request, files listed, no diff bodies", async () => { + render(); + + expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument(); + expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" }); + expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument(); + expect(screen.getByText("+12")).toBeInTheDocument(); + expect(screen.queryByText(/new line/)).toBeNull(); + }); + + it("opens one file's diff when its row is clicked, asking for that path only", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + vi.mocked(api.lanes.changes).mockResolvedValue( + changes({ + files: [ + { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + added: 12, + deleted: 3, + binary: false, + hunks: [HUNK], + }, + ], + }) + ); + await user.click(screen.getByText("server/lib/git-ops.js")); + + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "work", + path: "server/lib/git-ops.js", + context: 3, + }) + ); + // The highlighter splits a line into one span per token, so assert on the + // rendered text of the whole view rather than on a single node. + const view = await screen.findByTestId("lane-diff-view"); + expect(view).toHaveTextContent("new line"); + // Both gutters are rendered, so a reviewer can cite a line number. + expect(view).toHaveTextContent("keep"); + }); + + it("asks for every file's hunks in review mode", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "Review" })); + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true }) + ); + expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument(); + }); + + it("switches the comparison when the range toggle is used", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "vs base" })); + await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" })); + }); + + it("says so, quietly, when the lane's directory is not a git repo", async () => { + vi.mocked(api.lanes.changes).mockResolvedValue({ + available: false, + range: "work", + files: [], + }); + render(); + expect( + await screen.findByText("This lane's directory is not a readable git repository.") + ).toBeInTheDocument(); + }); + + it("stages one file and asks the server for that path alone", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + // Both rows offer it (one modified, one untracked); the first is the + // modified file the assertion below names. + const [stage] = screen.getAllByRole("button", { name: "Stage this file" }); + await user.click(stage!); + expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { + paths: ["server/lib/git-ops.js"], + }); + }); + + it("needs two clicks to discard, and only the second one reaches the server", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" }); + await user.click(discard!); + expect(api.lanes.gitAction).not.toHaveBeenCalled(); + + await user.click( + screen.getByRole("button", { name: "Click again to discard — this cannot be undone" }) + ); + expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", { + paths: ["server/lib/git-ops.js"], + confirm: true, + }); + }); + + it("opens a commit from history as a commit-range review", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "History" })); + await user.click(await screen.findByText("feat: the commit")); + + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "commit", + sha: "a".repeat(40), + full: true, + }) + ); + expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa"); + }); + + it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => { + const user = userEvent.setup(); + vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => + path === "src" + ? { + available: true, + path: "src", + entries: [{ name: "main.ts", path: "src/main.ts", dir: false }], + truncated: false, + } + : { + available: true, + path: "", + entries: [{ name: "src", path: "src", dir: true }], + truncated: false, + } + ); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "Files" })); + await user.click(await screen.findByText("src")); + + expect(await screen.findByText("main.ts")).toBeInTheDocument(); + // The root's "src" row is still there — expanding did not replace the list. + expect(screen.getByText("src")).toBeInTheDocument(); + }); + + it("expands every folder at once, fetching whatever level wasn't cached yet", async () => { + const user = userEvent.setup(); + vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => { + if (path === "src") + return { + available: true, + path: "src", + entries: [{ name: "main.ts", path: "src/main.ts", dir: false }], + truncated: false, + }; + return { + available: true, + path: "", + entries: [{ name: "src", path: "src", dir: true }], + truncated: false, + }; + }); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "Files" })); + await screen.findByText("src"); + await user.click(screen.getByRole("button", { name: "Expand all folders" })); + + expect(await screen.findByText("main.ts")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Collapse all folders" })); + expect(screen.queryByText("main.ts")).not.toBeInTheDocument(); + expect(screen.getByText("src")).toBeInTheDocument(); + }); + + it("quick-opens a tracked file as its whole content, not a diff", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + await user.click(screen.getByRole("button", { name: "Files" })); + await user.type(await screen.findByLabelText("Find a file by path…"), "main"); + await user.click(await screen.findByText("src/main.ts")); + + await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work")); + expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); + }); + + it("switches the diff to side-by-side", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + vi.mocked(api.lanes.changes).mockResolvedValue( + changes({ + files: [ + { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + added: 12, + deleted: 3, + binary: false, + hunks: [HUNK], + }, + ], + }) + ); + await user.click(screen.getByText("server/lib/git-ops.js")); + expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified"); + + await user.click(screen.getByRole("button", { name: "Side-by-side" })); + expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split"); + }); + + it("expands and then collapses the context again", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + await user.click(screen.getByText("server/lib/git-ops.js")); + await screen.findByRole("button", { name: "Show more surrounding lines" }); + + // Collapse is dead at the tightest step, and live once expanded — an + // expanded diff that cannot be collapsed sends the reader back out of the + // file to get the tight view. + expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "work", + path: "server/lib/git-ops.js", + context: 12, + }) + ); + + await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" })); + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "work", + path: "server/lib/git-ops.js", + context: 3, + }) + ); + }); + + it("asks for more context when the diff is expanded", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + await user.click(screen.getByText("server/lib/git-ops.js")); + await screen.findByText(/server\/lib\/git-ops\.js/); + + await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "work", + path: "server/lib/git-ops.js", + context: 12, + }) + ); + }); + + it("commits the index from the overview, never the whole tree", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + + // The box exists only because a file is staged — with a clean index there + // is nothing this button could mean. + const box = await screen.findByTestId("lane-changes-commit"); + await user.type(screen.getByLabelText("Commit message"), "fix: the staged half"); + await user.click(screen.getByRole("button", { name: "Commit 1 staged" })); + + expect(box).toBeInTheDocument(); + expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", { + message: "fix: the staged half", + only: "staged", + }); + }); + + it("hides the commit box when nothing is staged", async () => { + vi.mocked(api.lanes.changes).mockResolvedValue( + changes({ + files: [ + { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + unstaged: true, + added: 1, + deleted: 0, + binary: false, + }, + ], + }) + ); + render(); + await screen.findByTestId("lane-changes-overview"); + expect(screen.queryByTestId("lane-changes-commit")).toBeNull(); + }); + + it("puts Files first in the mode row", async () => { + render(); + await screen.findByTestId("lane-changes-overview"); + const modes = ["Files", "Overview", "Review", "History"].map((name) => + screen.getByRole("button", { name }) + ); + // Source order is the reading order here, so comparing positions pins it. + for (let i = 1; i < modes.length; i += 1) { + expect( + modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + } + }); + + it("folds one file shut in review mode, and all of them at once", async () => { + const user = userEvent.setup(); + const withHunks = changes({ + files: [ + { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + added: 1, + deleted: 1, + binary: false, + hunks: [HUNK], + }, + { + path: "notes.txt", + status: "??", + untracked: true, + added: 1, + deleted: 0, + binary: false, + hunks: [HUNK], + }, + ], + }); + render(); + await screen.findByTestId("lane-changes-overview"); + vi.mocked(api.lanes.changes).mockResolvedValue(withHunks); + await user.click(screen.getByRole("button", { name: "Review" })); + await screen.findByTestId("lane-changes-review"); + expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2); + + await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })); + expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1); + // The header stays, so a folded file is still a row you can reopen. + expect( + screen.getByRole("button", { name: "Expand server/lib/git-ops.js" }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Collapse every file" })); + expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0); + + await user.click(screen.getByRole("button", { name: "Expand every file" })); + expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2); + }); + + it("opens a file only from its path, and folds it from anywhere else", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + await user.click(screen.getByRole("button", { name: "Review" })); + await screen.findByTestId("lane-changes-review"); + + // Anywhere on the header but the path: fold, do not navigate. + await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })); + expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Expand server/lib/git-ops.js" }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" })); + await waitFor(() => + expect(api.lanes.changes).toHaveBeenCalledWith(7, { + range: "work", + path: "server/lib/git-ops.js", + context: 3, + }) + ); + }); + + it("goes back to the list it came from, at the offset it was left at", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("lane-changes-overview"); + await user.click(screen.getByRole("button", { name: "Review" })); + await screen.findByTestId("lane-changes-review"); + + // jsdom lays nothing out, so the scrollable box is described by hand — + // the restore deliberately waits until the list is tall enough again. + const body = screen.getByTestId("lane-changes-body"); + Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true }); + Object.defineProperty(body, "clientHeight", { value: 400, configurable: true }); + body.scrollTop = 640; + + await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" })); + await screen.findByRole("button", { name: /All files/ }); + + await user.click(screen.getByRole("button", { name: /All files/ })); + // Back to REVIEW (where the file was opened from), not to the overview. + expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument(); + expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640); + }); +}); diff --git a/client/src/components/lanes/__tests__/LaneDiffView.test.tsx b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx new file mode 100644 index 00000000..19d4a71f --- /dev/null +++ b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx @@ -0,0 +1,74 @@ +/** + * @file LaneDiffView.test.tsx + * @description Pins the side-by-side renderer: two independently scrolling + * columns (a long line must be reachable, not painted over its neighbour) + * whose horizontal scroll is mirrored, so the two halves of a line stay + * opposite each other. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { LaneDiffView } from "../LaneDiffView"; +import type { LaneChangeFile } from "../../../lib/types"; + +const FILE: LaneChangeFile = { + path: "server/lib/git-ops.js", + status: " M", + untracked: false, + added: 1, + deleted: 1, + binary: false, + hunks: [ + { + header: "@@ -1,2 +1,2 @@", + context: "", + oldStart: 1, + newStart: 1, + lines: [ + { type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` }, + { type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` }, + ], + }, + ], +}; + +describe("LaneDiffView — side by side", () => { + it("gives each column its own scroller", () => { + render(); + expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto"); + expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto"); + }); + + it("bounds its own height so the horizontal bar stays on screen", () => { + // A stacked review list caps each file; the single-file view takes the + // height its parent gives it. Either way the diff scrolls itself rather + // than growing a 2000-line container whose scrollbar is off the bottom. + const { rerender } = render(); + expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]"); + + rerender(); + expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1"); + expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]"); + }); + + it("mirrors one column's horizontal scroll onto the other", () => { + render(); + const left = screen.getByTestId("lane-diff-split-left"); + const right = screen.getByTestId("lane-diff-split-right"); + + left.scrollLeft = 320; + fireEvent.scroll(left); + expect(right.scrollLeft).toBe(320); + + right.scrollLeft = 0; + fireEvent.scroll(right); + expect(left.scrollLeft).toBe(0); + }); + + it("keeps the unified layout on one scroller", () => { + render(); + expect(screen.queryByTestId("lane-diff-split-left")).toBeNull(); + expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto"); + }); +}); diff --git a/client/src/components/lanes/__tests__/LaneGitBar.test.tsx b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx new file mode 100644 index 00000000..40e5a6e2 --- /dev/null +++ b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx @@ -0,0 +1,175 @@ +/** + * @file LaneGitBar.test.tsx + * @description Pins the lane git action row: the ahead/behind readout, which + * buttons a repo with no remote may press, that commit sends the typed + * message, that a dirty tree blocks the branch switch client-side (the server + * refuses it too), and that a failed action shows git's own words rather than + * a generic sentence. + * @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 { LaneGitBar } from "../LaneGitBar"; +import { api } from "../../../lib/api"; +import type { LaneGitFacts } from "../../../lib/types"; + +vi.mock("../../../lib/api", () => ({ + api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } }, +})); + +function facts(over: Partial> = {}): LaneGitFacts { + return { + available: true, + branch: "feat/thing", + head: "9b3e74a", + subject: "wip", + dirty: 2, + untracked: 1, + files: [], + hasRemote: true, + upstream: "origin/feat/thing", + ahead: 2, + behind: 1, + ...over, + }; +} + +beforeEach(() => { + vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({ + action: "fetch", + ok: true, + output: "", + }); + vi.mocked(api.lanes.gitBranches) + .mockReset() + .mockResolvedValue({ + available: true, + branches: ["development", "feat/thing"], + current: "feat/thing", + worktrees: [], + }); +}); + +describe("LaneGitBar", () => { + it("shows both sides of the upstream comparison", () => { + render(); + const tracking = screen.getByTestId("lane-git-tracking"); + expect(tracking).toHaveTextContent("2"); + expect(tracking).toHaveTextContent("1"); + }); + + it("disables the network actions when the repo has no remote", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled(); + // Local actions stay available — a repo with no remote still commits. + expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled(); + }); + + it("commits with the typed message and reports the change upward", async () => { + const user = userEvent.setup(); + const onChanged = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /^Commit$/ })); + await user.type(screen.getByLabelText("Commit message"), "fix: the thing"); + await user.click(screen.getByRole("button", { name: /Commit 3 files/ })); + + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" }) + ); + expect(onChanged).toHaveBeenCalled(); + }); + + it("offers no commit when nothing is uncommitted", () => { + render(); + expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled(); + }); + + it("loads the branch list on demand and refuses to switch while dirty", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Branch/ })); + await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3)); + await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); + // Picking is harmless; applying is what a dirty tree blocks. + expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled(); + expect(api.lanes.gitAction).not.toHaveBeenCalled(); + }); + + it("switches branch on a clean tree", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /Branch/ })); + const select = await screen.findByLabelText("Switch branch…"); + await user.selectOptions(select, "development"); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" }) + ); + }); + + it("creates a branch without switching, then offers a sibling lane on it", async () => { + const user = userEvent.setup(); + const onNewLane = vi.fn(); + render( + + ); + + await user.click(screen.getByRole("button", { name: /Branch/ })); + await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling"); + await user.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", { + branch: "feat/sibling", + }) + ); + + await user.selectOptions(screen.getByLabelText("Switch branch…"), "development"); + await user.click(screen.getByRole("button", { name: "New lane here" })); + expect(onNewLane).toHaveBeenCalledWith("development"); + }); + + it("commits only the index when asked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /^Commit$/ })); + await user.type(screen.getByLabelText(/Commit message/), "fix: staged only"); + await user.click(screen.getByRole("button", { name: "Commit staged" })); + + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { + message: "fix: staged only", + only: "staged", + }) + ); + }); + + it("prints git's own rejection text when an action fails", async () => { + const user = userEvent.setup(); + vi.mocked(api.lanes.gitAction).mockRejectedValue( + new Error("git push failed\n! [rejected] main -> main (non-fast-forward)") + ); + render(); + + await user.click(screen.getByRole("button", { name: /Push/ })); + expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward"); + }); +}); diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 4048c19e..2a3340f4 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -36,6 +36,7 @@ import { ShieldAlert, X, FolderOpen, + GitBranch, Home, History as HistoryIcon, Search, @@ -449,7 +450,10 @@ export function CwdAutocomplete({ // Group suggestions by kind preserving fixed order — home first, matching // the neutral default the page pre-fills (issue #202). const groups = useMemo(() => { - const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"]; + // "repo" (source repos lanes were cut from) sits above the recents: on the + // Add-lane form it is the group the field is actually asking for, and on + // the Run form it is a short, deliberate list rather than history. + const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"]; return order .map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) })) .filter((g) => g.items.length > 0); @@ -525,6 +529,8 @@ export function CwdAutocomplete({ ) : g.kind === "home" ? ( + ) : g.kind === "repo" ? ( + ) : ( )} diff --git a/client/src/components/workflows/CompactionImpact.tsx b/client/src/components/workflows/CompactionImpact.tsx index 81e990ca..8e3c0561 100644 --- a/client/src/components/workflows/CompactionImpact.tsx +++ b/client/src/components/workflows/CompactionImpact.tsx @@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) { {/* Hover tooltip (matches the app's other chart tooltips) */} {tip && (
    window.innerWidth - 220 ? tip.x - 14 : tip.x + 14, top: tip.y - 10, diff --git a/client/src/components/workflows/OrchestrationDAG.tsx b/client/src/components/workflows/OrchestrationDAG.tsx index 51eafb08..628b1cfa 100644 --- a/client/src/components/workflows/OrchestrationDAG.tsx +++ b/client/src/components/workflows/OrchestrationDAG.tsx @@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat ref={tipRef} role="tooltip" aria-hidden="true" - className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none" + className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none" style={{ display: "none", opacity: 0, @@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) { row.style.cssText = "display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6"; const lbl = document.createElement("span"); - lbl.style.color = "#64748b"; + lbl.style.color = "rgb(var(--fg-muted))"; lbl.textContent = label; const val = document.createElement("span"); - val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums"; + val.style.cssText = + "color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums"; val.textContent = value; row.appendChild(lbl); row.appendChild(val); @@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) { const desc = document.createElement("p"); desc.style.cssText = - "font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0"; + "font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0"; desc.textContent = meta.description; el.appendChild(desc); const hint = document.createElement("p"); - hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0"; + hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0"; hint.textContent = t("orchestration.tooltip.tipClickToFilter"); el.appendChild(hint); } diff --git a/client/src/components/workflows/SessionComplexityScatter.tsx b/client/src/components/workflows/SessionComplexityScatter.tsx index f3bc03ec..4f3b2dc8 100644 --- a/client/src/components/workflows/SessionComplexityScatter.tsx +++ b/client/src/components/workflows/SessionComplexityScatter.tsx @@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) { return (
    )}
    -
    +
    {t(`common:status.${state.item.status}`, { defaultValue: state.item.status })} diff --git a/client/src/components/workflows/WorkflowStats.tsx b/client/src/components/workflows/WorkflowStats.tsx index e4c35c14..e671d6c3 100644 --- a/client/src/components/workflows/WorkflowStats.tsx +++ b/client/src/components/workflows/WorkflowStats.tsx @@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
    -
    +
    {valueDisplay} diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 7b8b364b..5ad5d834 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -22,13 +22,19 @@ "add": "Add lane", "addLane": "Create a lane from a working directory", "addLaneBaseLabel": "Branch to fork from", - "addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.", + "addLaneBranchBusy": "{{branch}} — already checked out at {{path}}", + "addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.", + "addLaneBranchExistingLabel": "Branch to check out", + "addLaneBranchHintAuto": "Leave empty to use the default name shown above.", "addLaneBranchLabel": "New branch name", + "addLaneBranchModeCurrent": "Current branch", + "addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.", + "addLaneBranchModeExisting": "Existing branch", + "addLaneBranchModeNew": "New branch", "addLaneBranchPlaceholder": "feat/my-feature", "addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.", + "addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.", "addLaneNotARepo": "Not a git repository (or no read access) yet.", - "addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.", - "addLanePipelineLabel": "Pipeline template", "addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.", @@ -52,11 +58,63 @@ "addLaneToolClaude": "Claude Code", "addLaneToolCodex": "Codex", "addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.", - "autoStage": "auto: {{stage}}", + "addLaneWorktreeDetached": "(detached)", + "addLaneWorktreesMore": "+{{count}} more", + "addLaneWorktreesTitle": "Worktrees of this repo ({{count}})", "browse.button": "Browse", "browse.empty": "This folder is empty.", "browse.select": "Select this folder", "browse.title": "Browse for a folder", + "changes.back": "All files", + "changes.bin": "bin", + "changes.binary": "Binary file — not shown.", + "changes.collapse": "Collapse the changes panel", + "changes.collapseAll": "Collapse every file", + "changes.collapseAllFolders": "Collapse all folders", + "changes.collapseContext": "Show fewer surrounding lines", + "changes.collapseFile": "Collapse {{path}}", + "changes.commitPlaceholder": "Commit message", + "changes.commitStaged": "Commit {{count}} staged", + "changes.conflicted": "conflict", + "changes.discard": "Discard this file's changes", + "changes.discardConfirm": "Click again to discard — this cannot be undone", + "changes.empty.branch": "This branch matches its base.", + "changes.empty.commit": "This commit changed no files.", + "changes.empty.work": "Nothing uncommitted.", + "changes.expand": "Show the changes panel", + "changes.expandAll": "Expand every file", + "changes.expandAllFolders": "Expand all folders", + "changes.expandContext": "Show more surrounding lines", + "changes.expandFile": "Expand {{path}}", + "changes.fileGone": "That file no longer has changes in this range.", + "changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.", + "changes.layout.split": "Side-by-side", + "changes.layout.unified": "Unified", + "changes.loading": "Reading the working copy…", + "changes.mode.browse": "Files", + "changes.mode.history": "History", + "changes.mode.overview": "Overview", + "changes.mode.review": "Review", + "changes.noCommits": "No commits yet.", + "changes.noHunks": "No textual change.", + "changes.noMatches": "No tracked file matches.", + "changes.openFile": "Open {{path}}", + "changes.quickOpen": "Find a file by path…", + "changes.range.branch": "vs base", + "changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base", + "changes.range.work": "Uncommitted", + "changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed", + "changes.refresh": "Refresh", + "changes.resize": "Resize the changes panel (next: {{size}})", + "changes.size.compact": "Narrow", + "changes.size.half": "50 / 50", + "changes.size.wide": "70 / 30", + "changes.stage": "Stage this file", + "changes.title": "Changes", + "changes.truncated": "Some changes were left out to keep this panel responsive.", + "changes.unavailable": "This lane's directory is not a readable git repository.", + "changes.unstage": "Unstage this file", + "changes.wholeFile": "Whole file", "confirmRemoveCancel": "Cancel", "confirmRemoveConfirm": "Remove", "confirmRemoveMessage": "This action cannot be undone.", @@ -94,7 +152,7 @@ "destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.", "destructive.reset.title": "Reset managed worktree?", "destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.", - "empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:", + "empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:", "empty.title": "No lanes yet", "features.archived": "archived", "features.live": "Live", @@ -105,6 +163,36 @@ "git.filesMore_other": "… and {{count}} more files", "git.noDelta": "new", "git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", + "gitBar.branchCreate": "Create", + "gitBar.branchCreateHint": "Create this branch without switching to it", + "gitBar.branchDelete": "Delete", + "gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)", + "gitBar.branchNewPlaceholder": "New branch name…", + "gitBar.cancel": "Cancel", + "gitBar.commit": "Commit", + "gitBar.commitAll_one": "Commit {{count}} file", + "gitBar.commitAll_other": "Commit {{count}} files", + "gitBar.commitHint": "Stage everything and commit", + "gitBar.commitNothing": "Nothing to commit", + "gitBar.commitPlaceholder": "Commit message", + "gitBar.commitStaged": "Commit staged", + "gitBar.commitStagedHint": "Commit only what is staged in the Changes panel", + "gitBar.fetch": "Fetch", + "gitBar.fetchHint": "git fetch --all --prune", + "gitBar.inSync": "in sync", + "gitBar.newLane": "New lane here", + "gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch", + "gitBar.noUpstream": "not pushed", + "gitBar.pull": "Pull", + "gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged", + "gitBar.push": "Push", + "gitBar.pushHint": "git push -u origin HEAD", + "gitBar.switch": "Branch", + "gitBar.switchApply": "Switch branch", + "gitBar.switchCurrent": "current", + "gitBar.switchDirty": "Commit or discard the uncommitted changes first", + "gitBar.switchHint": "Switch this worktree to another local branch", + "gitBar.switchLabel": "Switch branch…", "kind.adopted": "adopted", "kind.managed": "managed", "laneDetail.hide": "Hide details", @@ -142,7 +230,6 @@ "splitView.paneCount": "{{count}} pane", "splitView.paneLaneLabel": "Pane lane selector", "splitView.pickLane": "Pick a lane", - "stageUndeclared": "not declared", "status.failed": "failed", "status.idle": "idle", "status.provisioning": "provisioning", diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index e78ed30b..0c86810d 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -61,6 +61,7 @@ "cwdGroups": { "dashboard": "Dashboard server", "home": "Home", + "repo": "Source repos", "recent": "Recent - used by Claude Code" }, "cwdHint": "Absolute path. Defaults to the dashboard's own cwd.", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 2b9964b1..a0c5e289 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -22,13 +22,19 @@ "add": "Thêm lane", "addLane": "Tạo lane từ một thư mục làm việc", "addLaneBaseLabel": "Nhánh để tạo nhánh mới", - "addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.", + "addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}", + "addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.", + "addLaneBranchExistingLabel": "Nhánh cần checkout", + "addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.", "addLaneBranchLabel": "Tên nhánh mới", + "addLaneBranchModeCurrent": "Nhánh hiện tại", + "addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.", + "addLaneBranchModeExisting": "Nhánh có sẵn", + "addLaneBranchModeNew": "Nhánh mới", "addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi", "addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", + "addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.", "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", - "addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.", - "addLanePipelineLabel": "Mẫu pipeline", "addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.", "addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", "addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.", @@ -52,11 +58,63 @@ "addLaneToolClaude": "Claude Code", "addLaneToolCodex": "Codex", "addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.", - "autoStage": "tự động: {{stage}}", + "addLaneWorktreeDetached": "(detached)", + "addLaneWorktreesMore": "+{{count}} nữa", + "addLaneWorktreesTitle": "Worktree của repo này ({{count}})", "browse.button": "Duyệt", "browse.empty": "Thư mục này trống.", "browse.select": "Chọn thư mục này", "browse.title": "Duyệt chọn thư mục", + "changes.back": "Tất cả file", + "changes.bin": "bin", + "changes.binary": "File nhị phân — không hiển thị.", + "changes.collapse": "Thu gọn bảng thay đổi", + "changes.collapseAll": "Thu gọn tất cả file", + "changes.collapseAllFolders": "Thu gọn tất cả thư mục", + "changes.collapseContext": "Bớt dòng xung quanh", + "changes.collapseFile": "Thu gọn {{path}}", + "changes.commitPlaceholder": "Nội dung commit", + "changes.commitStaged": "Commit {{count}} file đã stage", + "changes.conflicted": "xung đột", + "changes.discard": "Bỏ thay đổi của file này", + "changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác", + "changes.empty.branch": "Nhánh này không khác nhánh gốc.", + "changes.empty.commit": "Commit này không đổi file nào.", + "changes.empty.work": "Không có gì chưa commit.", + "changes.expand": "Mở bảng thay đổi", + "changes.expandAll": "Mở tất cả file", + "changes.expandAllFolders": "Mở tất cả thư mục", + "changes.expandContext": "Hiện thêm dòng xung quanh", + "changes.expandFile": "Mở {{path}}", + "changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.", + "changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.", + "changes.layout.split": "Hai cột", + "changes.layout.unified": "Một cột", + "changes.loading": "Đang đọc working copy…", + "changes.mode.browse": "Tệp", + "changes.mode.history": "Lịch sử", + "changes.mode.overview": "Tổng quan", + "changes.mode.review": "Xem toàn bộ", + "changes.noCommits": "Chưa có commit nào.", + "changes.noHunks": "Không có thay đổi nội dung.", + "changes.noMatches": "Không có file nào khớp.", + "changes.openFile": "Mở {{path}}", + "changes.quickOpen": "Tìm file theo đường dẫn…", + "changes.range.branch": "So nhánh gốc", + "changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc", + "changes.range.work": "Chưa commit", + "changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit", + "changes.refresh": "Tải lại", + "changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})", + "changes.size.compact": "Hẹp", + "changes.size.half": "50 / 50", + "changes.size.wide": "70 / 30", + "changes.stage": "Đưa file này vào index", + "changes.title": "Thay đổi", + "changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.", + "changes.unavailable": "Thư mục của lane này không phải repo git đọc được.", + "changes.unstage": "Bỏ file này khỏi index", + "changes.wholeFile": "Toàn bộ file", "confirmRemoveCancel": "Hủy", "confirmRemoveConfirm": "Xóa", "confirmRemoveMessage": "Hành động này không thể hoàn tác.", @@ -94,7 +152,7 @@ "destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.", "destructive.reset.title": "Đặt lại worktree được quản lý?", "destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.", - "empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Thêm ở đây, hoặc từ terminal:", + "empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:", "empty.title": "Chưa có lane nào", "features.archived": "đã lưu trữ", "features.live": "Phiên bản trực tiếp", @@ -105,6 +163,36 @@ "git.filesMore_other": "… và {{count}} tệp nữa", "git.noDelta": "mới", "git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", + "gitBar.branchCreate": "Tạo", + "gitBar.branchCreateHint": "Tạo nhánh này mà không chuyển sang", + "gitBar.branchDelete": "Xoá", + "gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)", + "gitBar.branchNewPlaceholder": "Tên nhánh mới…", + "gitBar.cancel": "Huỷ", + "gitBar.commit": "Commit", + "gitBar.commitAll_one": "Commit {{count}} file", + "gitBar.commitAll_other": "Commit {{count}} file", + "gitBar.commitHint": "Stage tất cả rồi commit", + "gitBar.commitNothing": "Không có gì để commit", + "gitBar.commitPlaceholder": "Nội dung commit", + "gitBar.commitStaged": "Commit phần đã stage", + "gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes", + "gitBar.fetch": "Fetch", + "gitBar.fetchHint": "git fetch --all --prune", + "gitBar.inSync": "đã đồng bộ", + "gitBar.newLane": "Tạo lane mới", + "gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn", + "gitBar.noUpstream": "chưa push", + "gitBar.pull": "Pull", + "gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge", + "gitBar.push": "Push", + "gitBar.pushHint": "git push -u origin HEAD", + "gitBar.switch": "Nhánh", + "gitBar.switchApply": "Chuyển nhánh", + "gitBar.switchCurrent": "hiện tại", + "gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước", + "gitBar.switchHint": "Đổi worktree này sang nhánh local khác", + "gitBar.switchLabel": "Đổi nhánh…", "kind.adopted": "đã nhận", "kind.managed": "được quản lý", "laneDetail.hide": "Ẩn chi tiết", @@ -142,7 +230,6 @@ "splitView.paneCount": "{{count}} ô", "splitView.paneLaneLabel": "Bộ chọn lane cho ô", "splitView.pickLane": "Chọn lane", - "stageUndeclared": "chưa khai báo", "status.failed": "thất bại", "status.idle": "rảnh", "status.provisioning": "đang khởi tạo", diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 7f913816..4784bee9 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -60,6 +60,7 @@ "cwdGroups": { "dashboard": "Dashboard server", "home": "Home", + "repo": "Repo nguồn", "recent": "Gần đây - Claude Code đã dùng" }, "cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index c894bd00..f7e92b0d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -396,10 +396,16 @@ import type { CostResult, DashboardEvent, Lane, + LaneChangeRange, + LaneChanges, + LaneCommit, + LaneFileContent, + LaneTreeEntry, LaneCounts, LaneGitFacts, LaneRuntime, LaneTool, + LaneWorktree, ModelPricing, NamedLock, PipelineStats, @@ -500,7 +506,11 @@ export function dashboardToken(): string | null { * @returns The parsed JSON response body, typed as `T`. * @throws {Error} When the response status is not ok (non-2xx). The thrown * message is `body.error.message` if the error body parsed as JSON and carried - * one, otherwise the literal `HTTP `. + * one, otherwise the literal `HTTP `. The server's own `error.code` + * (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every + * route's error body carries one (see `server/routes/*.js`), and a caller + * that needs to branch on which error this was should check `.code`, never + * guess at substrings of the human-readable `.message`. */ async function request(path: string, options?: RequestInit): Promise { const token = dashboardToken(); @@ -517,7 +527,10 @@ async function request(path: string, options?: RequestInit): Promise { // isn't JSON (or json() throws), fall back to an empty object so the `?.` // chain below cleanly degrades to the generic `HTTP ` message. const body = await res.json().catch(() => ({})); - throw new Error(body?.error?.message || `HTTP ${res.status}`); + throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), { + code: body?.error?.code, + status: res.status, + }); } return res.json(); } @@ -2304,11 +2317,13 @@ export const api = { /** * GET /api/lanes/branches — a candidate source repo's local branches. * @param repo Absolute path to an existing git repository. - * @returns `{ branches, current }` — local branch names (not `origin/*`) - * and which one is currently checked out (null if detached). + * @returns `{ branches, current, worktrees }` — local branch names (not + * `origin/*`), which one is currently checked out (null if detached), + * and the repo's existing worktrees, so the picker can grey out the + * branches git would refuse to check out a second time. */ branches: (repo: string) => - request<{ branches: string[]; current: string | null }>( + request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>( `/lanes/branches?repo=${encodeURIComponent(repo)}` ), /** @@ -2357,6 +2372,129 @@ export const api = { * readable git repository, which is a normal state rather than an error. */ git: (id: number) => request(`/lanes/${id}/git`), + /** + * GET /api/lanes/:id/git/changes — the lane's code changes, at the depth + * the Changes panel asks for. + * @param id The lane id. + * @param opts `range` picks the comparison (`work` = uncommitted vs HEAD, + * `branch` = base...HEAD). Pass `path` for one file's hunks, or + * `full: true` for every file's; with neither it is a summary. + * @returns {@link LaneChanges} — `available: false` for a cwd git cannot read. + */ + changes: ( + id: number, + opts: { + range?: LaneChangeRange; + path?: string; + full?: boolean; + sha?: string; + context?: number; + } = {} + ) => { + const q = new URLSearchParams(); + if (opts.range) q.set("range", opts.range); + if (opts.path) q.set("path", opts.path); + if (opts.full) q.set("full", "1"); + if (opts.sha) q.set("sha", opts.sha); + if (opts.context != null) q.set("context", String(opts.context)); + return request(`/lanes/${id}/git/changes?${q.toString()}`); + }, + /** + * GET /api/lanes/:id/git/log — the lane's recent commits, newest first. + * @param id The lane id. + * @param opts `file` narrows the list to the commits touching one path. + */ + gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => { + const q = new URLSearchParams(); + if (opts.limit) q.set("limit", String(opts.limit)); + if (opts.file) q.set("file", opts.file); + return request<{ available: boolean; commits: LaneCommit[] }>( + `/lanes/${id}/git/log?${q.toString()}` + ); + }, + /** + * GET /api/lanes/:id/git/file — one file's whole content at a revision. + * @param id The lane id. + * @param path Repo-relative path. + * @param rev `work` (the file on disk), `HEAD`, or a commit-ish. + */ + gitFile: (id: number, path: string, rev = "work") => + request( + `/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}` + ), + /** + * GET /api/lanes/:id/git/ls — tracked paths matching a substring, for + * quick-open. Ignored files never appear (it reads `git ls-files`). + * @param id The lane id. + * @param q The substring typed into the quick-open box. + */ + gitLs: (id: number, q: string) => + request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>( + `/lanes/${id}/git/ls?q=${encodeURIComponent(q)}` + ), + /** + * GET /api/lanes/:id/git/tree — one directory level of tracked files. + * @param id The lane id. + * @param path Repo-relative directory; "" for the repo root. + */ + gitTree: (id: number, path = "") => + request<{ + available: boolean; + path: string; + entries: LaneTreeEntry[]; + truncated: boolean; + }>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`), + /** + * GET /api/lanes/:id/git/branches — the lane repo's local branches, for + * the branch switcher. The repo comes from the lane row, never the query. + * @param id The lane id. + */ + gitBranches: (id: number) => + request<{ + available: boolean; + branches: string[]; + current: string | null; + worktrees: LaneWorktree[]; + }>(`/lanes/${id}/git/branches`), + /** + * POST /api/lanes/:id/git/:action — one git write on the lane's worktree. + * @param id The lane id. + * @param action `fetch`, `pull` (always --ff-only), `push`, `stage`, + * `unstage`, `discard`, `commit`, `switch`, `branch-create` or + * `branch-delete`. + * @param body `{message, only}` for commit; `{branch, create}` for switch; + * `{branch, base}` for branch-create; `{paths}` for stage/unstage; + * `{paths, confirm: true}` for discard — which is irreversible, so the + * server refuses it without that flag. + * @returns `{action, ok, output}` — `output` is git's own text, shown as-is. + */ + gitAction: ( + id: number, + action: + | "fetch" + | "pull" + | "push" + | "commit" + | "switch" + | "stage" + | "unstage" + | "discard" + | "branch-create" + | "branch-delete", + body: { + message?: string; + only?: "staged"; + branch?: string; + base?: string; + create?: boolean; + paths?: string[]; + confirm?: boolean; + } = {} + ) => + request<{ action: string; ok: true; output: string; branch?: string }>( + `/lanes/${id}/git/${action}`, + { method: "POST", body: JSON.stringify(body) } + ), /** * GET /api/lanes/:id/runtime — the lane's own application stack: slot, * ports, service liveness, and the last boot error. @@ -3476,7 +3614,7 @@ export interface DashboardRunHistoryItem { export interface CwdSuggestion { /** "dashboard" = this server's own cwd; "home" = user's home dir; "recent" * = previously used for a run. */ - kind: "dashboard" | "home" | "recent"; + kind: "dashboard" | "home" | "recent" | "repo"; path: string; label: string; } diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 1e37d604..a6407e10 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2594,6 +2594,15 @@ export type LaneGitFacts = untracked: number; /** The files behind the counts, capped; the counts themselves stay exact. */ files: LaneGitFile[]; + /** Whether the repo has any remote configured at all. */ + hasRemote: boolean; + /** `origin/`, or null when this branch has never been pushed. */ + upstream: string | null; + /** Commits HEAD has that the upstream does not; null without an upstream + * — not 0, which would claim "in sync" about a comparison never made. */ + ahead: number | null; + /** Commits the upstream has that HEAD does not; null without an upstream. */ + behind: number | null; } | { available: false }; @@ -2609,6 +2618,110 @@ export interface LaneGitFile { deleted: number | null; } +/** One entry of `git worktree list` for a repo. */ +export interface LaneWorktree { + /** Absolute path of the worktree's directory. */ + path: string; + /** The branch checked out there; absent for a detached worktree. */ + branch?: string; + locked?: boolean; +} + +/** Which comparison the Changes panel is showing. */ +export type LaneChangeRange = "work" | "branch" | "commit"; + +/** One row of `GET /api/lanes/:id/git/log`. */ +export interface LaneCommit { + sha: string; + short: string; + subject: string; + author: string; + /** ISO 8601 author date. */ + date: string; + /** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */ + refs: string[]; +} + +/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */ +export type LaneFileContent = + | { + available: true; + path: string; + rev: string; + binary: boolean; + truncated: boolean; + lines: string[]; + } + | { available: false }; + +/** One entry of `GET /api/lanes/:id/git/tree` — a folded directory or a file. */ +export interface LaneTreeEntry { + name: string; + /** Path relative to the repo root. */ + path: string; + dir: boolean; +} + +/** One row of a rendered diff — both gutters already resolved server-side. */ +export interface LaneDiffLine { + type: "ctx" | "add" | "del"; + /** Line number on the left (before) side; null on an added line. */ + oldLine: number | null; + /** Line number on the right (after) side; null on a removed line. */ + newLine: number | null; + text: string; +} + +/** One `@@` block of a file's diff. */ +export interface LaneDiffHunk { + header: string; + /** The function/section name git puts after the `@@`, when it found one. */ + context: string; + oldStart: number; + newStart: number; + lines: LaneDiffLine[]; +} + +/** One changed file, as `GET /api/lanes/:id/git/changes` reports it. */ +export interface LaneChangeFile { + path: string; + /** Two-character porcelain code for the `work` range, `"M "`-style for `branch`. */ + status: string; + untracked: boolean; + /** The file has changes in the index (`work` range only). */ + staged?: boolean; + /** The file has changes only in the working tree (`work` range only). */ + unstaged?: boolean; + /** The file is in a merge conflict (`work` range only). */ + conflicted?: boolean; + added: number | null; + deleted: number | null; + binary: boolean; + /** Absent in overview mode (no `full`/`path`), `[]` for a binary file. */ + hunks?: LaneDiffHunk[]; + /** This file's patch was cut short by a size cap. */ + truncated?: boolean; +} + +/** `GET /api/lanes/:id/git/changes`. */ +export type LaneChanges = + | { + available: true; + range: LaneChangeRange; + /** The lane's base branch name, for `range: "branch"`. */ + base: string | null; + /** The ref the base actually resolved to (`origin/main`, `main`, …). */ + baseRef: string | null; + /** The commit being shown, for `range: "commit"`; its full sha. */ + sha: string | null; + /** Lines of unchanged context each hunk carries. */ + context: number; + files: LaneChangeFile[]; + /** A cap bit: the file list or some patch was cut short. */ + truncated: boolean; + } + | { available: false; range: LaneChangeRange; files: LaneChangeFile[] }; + /** Aggregate counts across all lanes. */ export interface LaneCounts { total: number; diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index eecf6346..6e5ebdcb 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -169,7 +169,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac const nearRight = x > window.innerWidth - 200; return (

    {t("pricing.tooltip.title")}

    diff --git a/client/src/pages/Workflows.tsx b/client/src/pages/Workflows.tsx index 1d157d7c..d94cb170 100644 --- a/client/src/pages/Workflows.tsx +++ b/client/src/pages/Workflows.tsx @@ -435,10 +435,10 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
    -

    +

    {title}

    diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index a3c38d39..dcf799eb 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -43,7 +43,17 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Plus, Square, Columns2, Grid2x2, Pencil, Layers } from "lucide-react"; +import { + Plus, + Square, + Columns2, + Grid2x2, + Pencil, + Layers, + PanelLeftClose, + ChevronsLeftRight, + PanelRightClose, +} from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types"; @@ -65,10 +75,36 @@ import LaneCard, { } from "../components/lanes/LaneCard"; import LaneStripCard from "../components/lanes/LaneStripCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; +import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage"; import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage"; +/** localStorage key for the Changes panel's open/closed state. */ +const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen"; +/** localStorage key for how wide the Changes panel is. */ +const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize"; + +/** The three widths the Changes panel cycles through, then closed. Reading a + * diff and watching a console want opposite amounts of room, and which one is + * "work" changes several times an hour — hence ONE control that steps + * narrow → 50/50 → 70/30 → closed, rather than a width button beside a + * collapse button that both live in the panel's tightest corner. */ +const CHANGES_SIZES = ["compact", "half", "wide"] as const; +type ChangesSize = (typeof CHANGES_SIZES)[number]; +const CHANGES_WIDTH: Record = { + compact: "w-[28rem] xl:w-[34rem]", + half: "w-1/2", + wide: "w-[70%]", +}; +/** The icon the one resize control shows AT each size: what the next click + * gives you — an even split, a wider panel, then closed. */ +const CHANGES_NEXT_ICON: Record = { + compact: Columns2, + half: ChevronsLeftRight, + wide: PanelRightClose, +}; + // ── Page ────────────────────────────────────────────────────────────── /** One pane, two side by side, four in a grid — the conventional icons for @@ -206,6 +242,12 @@ export function Workspace() { const [selectedLaneId, setSelectedLaneId] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [addLaneOpen, setAddLaneOpen] = useState(false); + // Fields the Add-lane modal opens with when it was opened from a lane's git + // bar ("new lane here"): one provisioning path for every lane, prefilled. + const [addLanePrefill, setAddLanePrefill] = useState<{ + sourceRepo?: string; + branch?: string; + } | null>(null); const [viewedFeatureSlug, setViewedFeatureSlug] = useState(null); const [features, setFeatures] = useState([]); const [pipelineTemplates, setPipelineTemplates] = useState< @@ -239,6 +281,44 @@ export function Workspace() { // single-pane view and collapsed in split view, but the toggle below can // override that in either mode — the user decides, not the layout. const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1); + // Whether the Changes panel is open. A viewer preference, so it lives in + // localStorage and never reaches the server — same rule as the lane strip's + // order (client/src/lib/laneOrderStorage.ts). + const [changesOpen, setChangesOpen] = useState(() => { + try { + return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1"; + } catch { + return false; + } + }); + useEffect(() => { + try { + window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0"); + } catch { + /* private mode / disabled storage: the panel just forgets between visits */ + } + }, [changesOpen]); + const [changesSize, setChangesSize] = useState(() => { + try { + const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null; + return saved && CHANGES_SIZES.includes(saved) ? saved : "compact"; + } catch { + return "compact"; + } + }); + useEffect(() => { + try { + window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize); + } catch { + /* same as above: a forgotten width, not an error */ + } + }, [changesSize]); + const nextChangesSize = + CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!; + const changesNextLabel = + changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1] + ? tLanes("changes.collapse") + : tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) }); const showInfo = infoExpanded; const setLayout = useCallback((layout: SplitLayout) => { @@ -579,15 +659,19 @@ export function Workspace() { which reads as a half-rendered row rather than a state. One banner instead — and a compact one on purpose: the run setup form below is what the user actually needs the height for. */ -
    setAddLaneOpen(true)} + className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2" >
    -

    {tLanes("empty.title")}

    + + {tLanes("empty.title")} +

    {tLanes("empty.body")}{" "} @@ -595,16 +679,11 @@ export function Workspace() {

    - -
    + + ) : (
    - {/* One header row, not two. The pipeline picker, the stage badges, + {/* Detail and the Changes panel share one row: the panel is a reading + surface for the same lane the console is driving, so it sits beside + it rather than under it. It collapses to a rail instead of + unmounting nothing — there is no live subscription inside it, so + unmounting is safe and keeps a closed panel from polling. */} +
    +
    + {/* One header row, not two. The pipeline picker, the stage badges, the feature picker and the router select used to sit on a second row below this one; every one of them is a per-lane control that belongs beside the lane's own name, and stacking them cost a whole @@ -658,245 +743,297 @@ export function Workspace() { down. The pipeline's NAME is no longer interpolated into `laneHeader` either — the picker right next to it already says it. They stay gated on `showInfo`, same as before the merge. */} - {currentLane && ( -
    - - - {currentLane.liveness === "dead" - ? tLanes("statusDead") - : tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })} - - {/* The lane's name, and the one place it can be set. No "Lane 23 ·" + {currentLane && ( +
    + + + {currentLane.liveness === "dead" + ? tLanes("statusDead") + : tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })} + + {/* The lane's name, and the one place it can be set. No "Lane 23 ·" prefix any more: the id is on the strip tile, where the CLI's `--lane` reader looks for it, and repeating it here only ate the width the name needed. Submitting an empty name clears `title`, which puts the cwd back in its place. */} - {renamingLaneId === currentLane.id ? ( - setRenameDraft(e.target.value)} - // Blur CANCELS, it does not save — same convention as the - // rename field in CcConfig. Committing here too would fire - // the PATCH twice for one Enter: the commit unmounts the - // focused input, and removing a focused element raises a - // native blur before the refetch has moved `lanes`, so the - // "unchanged title" guard would not catch the second call. - onBlur={() => setRenamingLaneId(null)} - onKeyDown={(e) => { - if (e.key === "Enter") void commitRename(currentLane.id); - if (e.key === "Escape") setRenamingLaneId(null); - }} - maxLength={200} - className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary" - /> - ) : ( - - )} - - {since(currentLane.stage_seconds)} - - {showInfo && ( - <> - setRenameDraft(e.target.value)} + // Blur CANCELS, it does not save — same convention as the + // rename field in CcConfig. Committing here too would fire + // the PATCH twice for one Enter: the commit unmounts the + // focused input, and removing a focused element raises a + // native blur before the refetch has moved `lanes`, so the + // "unchanged title" guard would not catch the second call. + onBlur={() => setRenamingLaneId(null)} + onKeyDown={(e) => { + if (e.key === "Enter") void commitRename(currentLane.id); + if (e.key === "Escape") setRenamingLaneId(null); + }} + maxLength={200} + className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary" + /> + ) : ( + + )} + + {since(currentLane.stage_seconds)} + + {showInfo && ( + <> - )} - - )} -
    - {/* Per-lane actions (agents install, mcp sync, sync-base check) + + {/* No stage chip and no "auto: " chip here: the + PipelineMap right below this header already draws both + — the declared node as the current ring, the detected + one as its amber badge — so the chips were a second, + smaller copy of the same two facts. */} + {features.length > 0 && ( + + )} + + )} +
    + {/* Per-lane actions (agents install, mcp sync, sync-base check) belong beside the lane they act on, not in a page header. */} - - - - {showInfo && ( + - )} -
    -
    - )} - {currentLane && showInfo && ( -
    -
    - handleLaneAction(currentLane.id, a, b)} - childWorktrees={lanes.filter( - (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id + + {showInfo && ( + )} - onSelectLane={setSelectedLaneId} - /> +
    -
    - - {viewedFeature && ( -

    - {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} -

    - )} -
    - {proofFeature && - (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( -
    - {proofFeature.ticket_report && ( - - {tLanes("proof.ticketReport")} - + )} + {currentLane && showInfo && ( +
    +
    + handleLaneAction(currentLane.id, a, b)} + childWorktrees={lanes.filter( + (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id )} - {Object.entries(proofFeature.groups).map(([group, images]) => ( -
    - - {group} · {images.length} - -
    - {images.slice(0, 8).map((img) => ( - {img} - ))} - {images.length > 8 && ( - +{images.length - 8} - )} + onSelectLane={setSelectedLaneId} + onNewLaneFromRepo={(repo, branch) => { + setAddLanePrefill({ sourceRepo: repo, branch }); + setAddLaneOpen(true); + }} + /> +
    +
    + + {viewedFeature && ( +

    + {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} +

    + )} +
    + {proofFeature && + (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( +
    + {proofFeature.ticket_report && ( + + {tLanes("proof.ticketReport")} + + )} + {Object.entries(proofFeature.groups).map(([group, images]) => ( +
    + + {group} · {images.length} + +
    + {images.slice(0, 8).map((img) => ( + {img} + ))} + {images.length > 8 && ( + +{images.length - 8} + )} +
    -
    - ))} -
    - )} -
    - )} -
    - {/* `showToggle`: the lane header carries its own split toggle; with + ))} +
    + )} +
    + )} +
    + {/* `showToggle`: the lane header carries its own split toggle; with no lane there is no header, so the console shows one instead. */} - - setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) - } - onLaneIdChange={setSelectedLaneId} - showToggle={!currentLane} - /> -
    -
    + + setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) + } + onLaneIdChange={setSelectedLaneId} + showToggle={!currentLane} + /> +
    + + + {currentLane && + (changesOpen ? ( + + ) : ( + + ))} +
    {currentLane && removeConfirmOpen && ( @@ -915,7 +1052,11 @@ export function Workspace() { setAddLaneOpen(false)} + prefill={addLanePrefill} + onClose={() => { + setAddLaneOpen(false); + setAddLanePrefill(null); + }} onAdded={(lane) => { setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])); setSelectedLaneId(lane.id); diff --git a/client/src/pages/__tests__/Workspace.changesPanel.test.tsx b/client/src/pages/__tests__/Workspace.changesPanel.test.tsx new file mode 100644 index 00000000..e29c64e2 --- /dev/null +++ b/client/src/pages/__tests__/Workspace.changesPanel.test.tsx @@ -0,0 +1,182 @@ +/** + * @file Workspace.changesPanel.test.tsx + * @description The Changes panel is ONE control: it steps narrow → 50/50 → + * 70/30 → closed, and the rail reopens it narrow. The width survives a reload. + * All of it lives in localStorage and reaches no server — the same rule as the + * lane strip's order. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act, cleanup, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import i18n from "i18next"; + +// Hoisted: `vi.mock`'s factory is lifted above the module body, so the +// fixtures it reads have to be lifted with it. +const { HOME, LANE_A, LANE_B } = vi.hoisted(() => { + const laneStub = (id: number, cwd: string) => ({ + id, + title: `lane-${id}`, + cwd, + branch: null, + kind: "adopted", + source_repo: null, + pipeline: "default", + session_id: null, + run_id: null, + stage: "idle", + stage_since: null, + status: "idle", + gate_decision: null, + ci_status: null, + needs_action: null, + links: {}, + stages: {}, + notes: null, + pipeline_name: "Default", + pipeline_nodes: [], + progress: 0, + stage_seconds: null, + last_event_seconds: null, + liveness: "idle", + detected_stage: null, + detected_signal: null, + slot: null, + ports: {}, + active_feature_id: null, + }); + return { + HOME: { kind: "home", path: "/Users/tester", label: "Home" }, + LANE_A: laneStub(1, "/workspace/alpha"), + LANE_B: laneStub(2, "/workspace/beta"), + }; +}); + +vi.mock("../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + const r = (value: unknown) => vi.fn().mockResolvedValue(value); + return { + ...actual, + api: { + run: { + list: r({ items: [] }), + history: r({ items: [] }), + binary: r({ found: true, path: "/usr/bin/claude" }), + cwds: r({ items: [HOME] }), + files: r({ items: [] }), + start: r({ id: "run-1", status: "running" }), + get: r({ id: "run-1", status: "running" }), + }, + lanes: { + list: r({ + lanes: [LANE_A, LANE_B], + counts: { total: 2, running: 0, needs_you: 0, dead: 0 }, + }), + pipelines: r({ pipelines: [] }), + git: r({ available: false }), + runtime: r({ up: false, ports: {}, lastError: null }), + features: { list: r({ features: [] }), show: r({ feature: null }) }, + proof: { list: r({ features: [] }), imageUrl: () => "" }, + }, + aiRouters: { list: r({ items: [] }) }, + sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) }, + }, + }; +}); + +vi.mock("../../lib/eventBus", () => ({ + eventBus: { + subscribe: () => () => {}, + publish: () => {}, + onConnection: () => () => {}, + connected: true, + setConnected: () => {}, + }, +})); + +import { Workspace } from "../Workspace"; + +class ObserverStub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} +globalThis.ResizeObserver = + globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver); + +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +beforeEach(() => { + i18n.changeLanguage("en"); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("Workspace — the Changes panel's width", () => { + beforeEach(() => window.localStorage.clear()); + + async function openPanel() { + render( + + + + ); + await settle(); + // Open/closed is itself remembered, so the second mount of a test comes up + // already open — clicking the rail then would close it. + const rail = screen.queryByTestId("lane-changes-expand"); + if (rail) { + fireEvent.click(rail); + await settle(); + } + } + + it("steps narrow → 50/50 → 70/30 → closed on one control", async () => { + await openPanel(); + const card = () => screen.getByTestId("lane-changes-card"); + expect(card().className).toContain("w-[28rem]"); + + fireEvent.click(screen.getByTestId("lane-changes-resize")); + expect(card().className).toContain("w-1/2"); + + fireEvent.click(screen.getByTestId("lane-changes-resize")); + expect(card().className).toContain("w-[70%]"); + + // Past the widest step the panel closes — and reopens narrow, not back at + // 70/30, which is why closing resets the width. + fireEvent.click(screen.getByTestId("lane-changes-resize")); + await settle(); + expect(screen.queryByTestId("lane-changes-card")).toBeNull(); + + fireEvent.click(screen.getByTestId("lane-changes-expand")); + await settle(); + expect(card().className).toContain("w-[28rem]"); + }); + + it("remembers the width across a reload, and never asks the server", async () => { + await openPanel(); + fireEvent.click(screen.getByTestId("lane-changes-resize")); + expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half"); + + cleanup(); + await openPanel(); + expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2"); + }); + + it("keeps the rail as the only way back in", async () => { + await openPanel(); + expect(screen.queryByTestId("lane-changes-collapse")).toBeNull(); + expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index c796df62..36231bc5 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -5099,9 +5099,10 @@ exports[`screen snapshots > Run 1`] = `
    -
    Run 1`] = `
    -

    No lanes yet -

    +

    - A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal: + A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal: Run 1`] = `

    - -
    -
    + +
    -
    - - - -
    -
    -
    -
    - - - -
    -
    -
    -

    - Run Claude -

    - - - Live - -
    - -
    - -
    -
    + + + + + + +
    +
    +
    -
    -
    +
    +
    +

    - Working directory - -
    + + + Live + +
    + +

    + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    -
    -
    - -
    +
    +
    @@ -5442,7 +5519,7 @@ exports[`screen snapshots > Run 1`] = ` - Inherit from settings + Default (model decides) Run 1`] = `
    -
    -
    -
    -
    - -
    - -
    + Resume existing session +
    - -