Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 470f9909b4 | |||
| 5ba9e07d78 | |||
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 |
+2
-1
@@ -346,8 +346,9 @@ graph TD
|
||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
|
||||
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v1.6.0 (2026-09-14) + 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.6.9 (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 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.
|
||||
@@ -25,7 +25,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `agenthub stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
|
||||
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||
@@ -38,6 +38,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **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 `<cwd>/.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/<name>.config.toml` — the layer `codex -p <name>` 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 `<CLAUDE_HOME>/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`.
|
||||
- **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.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AddLaneModal({
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
setSetupResult({
|
||||
const outcome = {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled"
|
||||
? profileOutcome.value.scaffolded
|
||||
@@ -308,7 +308,9 @@ export function AddLaneModal({
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
});
|
||||
} as const;
|
||||
const anyFailed = Object.values(outcome).includes("failed");
|
||||
setSetupResult(outcome);
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
@@ -326,10 +328,17 @@ export function AddLaneModal({
|
||||
});
|
||||
}
|
||||
|
||||
// Leave the modal open so the setup summary below stays on screen; the
|
||||
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||
// than racing a timer that can close before they've looked at it.
|
||||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||||
// dialog is done — leaving it up read as "still working" (the confirm
|
||||
// button is dead from here, since the lane already exists) with no
|
||||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||||
// and then the confirm button becomes a plain close, because that is the
|
||||
// one case where the user has something to read before dismissing it.
|
||||
setBusy(false);
|
||||
if (!anyFailed) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
@@ -354,21 +363,24 @@ export function AddLaneModal({
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
// the confirm button has no second creation to run: it closes instead. That
|
||||
// is the difference between a dialog that is finished and one that is stuck.
|
||||
const done = !!setupResult;
|
||||
const disabled =
|
||||
!!setupResult ||
|
||||
!sourceRepo.trim() ||
|
||||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||
!done &&
|
||||
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={t("add")}
|
||||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onConfirm={submit}
|
||||
onConfirm={done ? handleCancel : submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
* NOT repeated here — this card only ever renders inside the Workspace
|
||||
* lane-detail section, whose header above it already shows them. The repo
|
||||
* lane-detail section, whose header above it already shows them. Neither is
|
||||
* progress: the pipeline map directly below this card draws the same number
|
||||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
@@ -16,17 +19,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* each call is five git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
@@ -164,6 +168,16 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
setNote(null);
|
||||
}, [lane.id, lane.router_profile]);
|
||||
|
||||
// One instance of this row serves every lane — it lives in the Workspace's
|
||||
// lane-detail header, and switching lanes swaps its `lane` prop rather than
|
||||
// remounting it. So an apply that is still in flight when the user moves on
|
||||
// would land its rollback and its note on whatever lane is showing by then:
|
||||
// lane B's failed switch putting lane B's old selection into lane A's picker.
|
||||
const shownLane = useRef(lane.id);
|
||||
useEffect(() => {
|
||||
shownLane.current = lane.id;
|
||||
}, [lane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiRouters
|
||||
@@ -184,17 +198,22 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
if (!routers || routers.length <= 1) return null;
|
||||
|
||||
const change = async (name: string) => {
|
||||
const laneId = lane.id;
|
||||
const previous = current;
|
||||
setCurrent(name);
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
await api.lanes.routerApply(lane.id, name);
|
||||
await api.lanes.routerApply(laneId, name);
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.applied") });
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setCurrent(previous);
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
// `busy` belongs to the control on screen, not to the request, so it is
|
||||
// cleared either way — a lane switch must never leave the picker locked.
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -376,6 +395,141 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`, for the same reason this
|
||||
* file's own header says the tools dropdown was moved out of the card: a lane
|
||||
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
|
||||
* an absolutely positioned panel would be clipped by it for every card that is
|
||||
* not at the very top. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
@@ -396,6 +550,21 @@ export default function LaneCard({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
@@ -427,23 +596,6 @@ export default function LaneCard({
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
>
|
||||
{/* Progress line: how far through the pipeline. The stage's own name
|
||||
and how long it's been running are already in the Workspace
|
||||
header above; not repeated here. Omitted at 0% — an empty bar
|
||||
is not information, just a blank row above a dead/idle lane. */}
|
||||
{lane.progress > 0 && (
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
@@ -544,18 +696,42 @@ export default function LaneCard({
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="mb-2 flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<span className="shrink-0 text-status-warning/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</span>
|
||||
<div className="mb-2">
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
*
|
||||
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
||||
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
||||
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
||||
* the keyboard (and touch-free) path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
@@ -45,33 +51,69 @@ function detectionLeads(lane: Lane): boolean {
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
onReorder,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("reorderHint")}`}
|
||||
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||
width and said nothing, but the number itself is how the CLI
|
||||
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
|
||||
@@ -242,9 +242,10 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||
);
|
||||
// The modal stays open showing the setup summary until dismissed - it
|
||||
// does not close itself just because the lane was added.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
// Setup came back clean, so there is nothing to report and the dialog is
|
||||
// done. Leaving it up read as "still working" — its confirm button is dead
|
||||
// once the lane exists — which is how it looked stuck.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
@@ -305,11 +306,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||
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.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
@@ -317,12 +319,13 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
// A failure is the one case worth reading before the dialog goes away.
|
||||
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||
if (!dismissButton) throw new Error("Cancel button not found");
|
||||
await user.click(dismissButton);
|
||||
// And then the primary button must close, not sit there disabled: the lane
|
||||
// already exists, so "Add lane" has nothing left to do.
|
||||
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
@@ -155,14 +155,12 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage", () => {
|
||||
// The pipeline map below this card draws the same progress as named nodes,
|
||||
// so the card no longer repeats it as a bar and a percent.
|
||||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
@@ -209,6 +207,11 @@ describe("LaneCard git block", () => {
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
@@ -224,6 +227,57 @@ describe("LaneCard git block", () => {
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
@@ -459,6 +513,60 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||||
// lane-detail header), so its selected value is component state that
|
||||
// outlives a lane switch unless the sync effect puts the new lane's own
|
||||
// profile back.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/b/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
});
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
// The row is one instance shared by every lane, so an apply still in flight
|
||||
// when the user moves on would paint its rollback and its error on whoever
|
||||
// is on screen by then — lane B's failure changing lane A's picker.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
let reject: (e: Error) => void = () => {};
|
||||
vi.mocked(api.lanes.routerApply).mockImplementation(
|
||||
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
|
||||
);
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
await act(async () => {
|
||||
reject(new Error("EBADJSON"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||||
// And the picker must not be left locked by a request for another lane.
|
||||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("puts the selection back when the apply fails", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
||||
* AHEAD of the declared one, including when the declaration used a node alias
|
||||
* (which is most of what the ship-feature skill declares).
|
||||
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||
* declared one, including when the declaration used a node alias — which is
|
||||
* most of what the ship-feature skill declares), and the reorder handles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LaneStripCard from "../LaneStripCard";
|
||||
import type { Lane, LaneNode } from "../../../lib/types";
|
||||
|
||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneStripCard reorder", () => {
|
||||
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
|
||||
function dataTransfer(initial = "") {
|
||||
let payload = initial;
|
||||
return {
|
||||
setData: (_type: string, v: string) => {
|
||||
payload = v;
|
||||
},
|
||||
getData: () => payload,
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
};
|
||||
}
|
||||
|
||||
it("reports the dragged tile's index and the drop target's", () => {
|
||||
const onReorder = vi.fn();
|
||||
const dt = dataTransfer();
|
||||
render(
|
||||
<>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 1 })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 2 })}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
|
||||
expect(onReorder).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it("ignores a drop carrying no tile index", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-1");
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 1);
|
||||
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 3);
|
||||
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft" });
|
||||
expect(onReorder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the id without the 'Lane' prefix", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 7, title: "checkout flow" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-7");
|
||||
expect(tile).toHaveTextContent("#7");
|
||||
expect(tile.textContent).not.toMatch(/Lane/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,13 +426,17 @@ export function LaneConsolePane({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||
description of the panel, not a message, so it does not earn the
|
||||
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||
where the width would wrap it again. */}
|
||||
<header className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<h1 className="text-base font-semibold text-fg-primary">
|
||||
{t("title", { tool: toolLabel })}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
@@ -447,7 +451,9 @@ export function LaneConsolePane({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
||||
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||
{t("subtitle", { tool: toolLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
|
||||
@@ -147,9 +147,83 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||
settings left, source right. They used to be two rows with a divider
|
||||
between them, which spent a band of height on four labelled selects
|
||||
that are each one line tall. The resume picker below is the only part
|
||||
that still gets a row of its own, and only while resume is chosen. */}
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
@@ -166,8 +240,11 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
{props.tool === "claude" ? (
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
@@ -192,8 +269,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -214,78 +291,6 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -235,6 +235,14 @@
|
||||
"filterAll": "All types"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Check quota",
|
||||
"usageChecking": "Checking…",
|
||||
"usageRefresh": "Refresh",
|
||||
"usageRetry": "Try again",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7d",
|
||||
"usageResets": "Resets {{time}}",
|
||||
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
|
||||
"addLaneRepoLabel": "Source repository",
|
||||
"addLaneRepoLabelAdopt": "Directory",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -54,7 +55,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"cardId": "Lane {{id}}",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
@@ -93,12 +94,17 @@
|
||||
"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.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"git.filesHeading_one": "{{count}} file changed",
|
||||
"git.filesHeading_other": "{{count}} files changed",
|
||||
"git.filesMore_one": "… and {{count}} more file",
|
||||
"git.filesMore_other": "… and {{count}} more files",
|
||||
"git.noDelta": "new",
|
||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}}",
|
||||
"renameLabel": "Rename this lane",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"moreActions": "More actions",
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
|
||||
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
||||
"title": "Run {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -230,6 +230,14 @@
|
||||
"filterAll": "Tất cả loại"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Kiểm tra quota",
|
||||
"usageChecking": "Đang kiểm tra…",
|
||||
"usageRefresh": "Làm mới",
|
||||
"usageRetry": "Thử lại",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7 ngày",
|
||||
"usageResets": "Reset lúc {{time}}",
|
||||
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"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.",
|
||||
"addLaneRepoLabel": "Repo nguồn",
|
||||
"addLaneRepoLabelAdopt": "Thư mục",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -54,7 +55,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"cardId": "Làn đường {{id}}",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
@@ -93,12 +94,17 @@
|
||||
"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.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"git.filesHeading_one": "{{count}} tệp thay đổi",
|
||||
"git.filesHeading_other": "{{count}} tệp thay đổi",
|
||||
"git.filesMore_one": "… và {{count}} tệp nữa",
|
||||
"git.filesMore_other": "… và {{count}} tệp nữa",
|
||||
"git.noDelta": "mới",
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}}",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"moreActions": "Thêm hành động",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
|
||||
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
|
||||
"title": "Chạy {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file laneOrderStorage.test.ts
|
||||
* @description Tests for the Workspace lane strip's stored card order: the
|
||||
* storage round-trip, and the two rules that keep a stale stored order from
|
||||
* losing a lane — unknown ids land at the end, vanished ids are skipped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("laneOrderStorage", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("returns an empty order when nothing is stored", () => {
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips a written order", () => {
|
||||
writeLaneOrder([3, 1, 2]);
|
||||
expect(readLaneOrder()).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("falls back to an empty order when stored JSON is malformed", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", "{not json");
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops non-numeric entries from a stored order", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, "2", null, 3]));
|
||||
expect(readLaneOrder()).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStoredOrder", () => {
|
||||
it("keeps server order when nothing is stored", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [])).toEqual(lanes(1, 2, 3));
|
||||
});
|
||||
|
||||
it("applies the stored order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [3, 1, 2])).toEqual(lanes(3, 1, 2));
|
||||
});
|
||||
|
||||
it("appends lanes the stored order has never seen, in server order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3, 4), [3, 1])).toEqual(lanes(3, 1, 2, 4));
|
||||
});
|
||||
|
||||
it("skips stored ids whose lane no longer exists", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 3), [3, 2, 1])).toEqual(lanes(3, 1));
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const input = lanes(1, 2, 3);
|
||||
sortByStoredOrder(input, [3, 2, 1]);
|
||||
expect(input.map((l) => l.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLane", () => {
|
||||
it("moves a card forward", () => {
|
||||
expect(moveLane([1, 2, 3], 0, 2)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it("moves a card backward", () => {
|
||||
expect(moveLane([1, 2, 3], 2, 0)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns the order unchanged for a no-op or out-of-range move", () => {
|
||||
expect(moveLane([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -1584,6 +1584,21 @@ export const api = {
|
||||
"/cc-config/profiles/activate",
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
/**
|
||||
* POST /api/cc-config/profiles/usage - how much of this profile's account
|
||||
* quota is already spent.
|
||||
*
|
||||
* A POST because answering it is a write: a saved profile's access token has
|
||||
* almost always expired, so the server refreshes it against Anthropic first
|
||||
* and the refresh rotates the credentials stored on disk. That is also why
|
||||
* this is never called on a timer — only when the user asks for one profile.
|
||||
* No token material crosses the boundary in either direction.
|
||||
*/
|
||||
profileUsage: (name: string) =>
|
||||
request<CcProfileUsage>("/cc-config/profiles/usage", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
|
||||
deleteProfile: (name: string) =>
|
||||
request<{ ok: true; name: string }>("/cc-config/profiles", {
|
||||
@@ -2656,6 +2671,25 @@ export interface CcProfile extends CcAccountIdentity {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
|
||||
export interface CcUsageWindow {
|
||||
/** Percent of the window's allowance already spent, 0-100. */
|
||||
utilization: number | null;
|
||||
/** ISO timestamp the window rolls over at, when the account reports one. */
|
||||
resetsAt: string | null;
|
||||
}
|
||||
|
||||
/** An account's spent quota, for the profile switcher. Percentages and reset
|
||||
* times only — the OAuth token stays on the server. */
|
||||
export interface CcProfileUsage {
|
||||
name: string;
|
||||
/** True when the stored token had expired and was refreshed to answer this. */
|
||||
refreshed: boolean;
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file laneOrderStorage.ts
|
||||
* @description Persists the Workspace lane strip's user-chosen card order to
|
||||
* localStorage, the same read/validate/fallback convention as
|
||||
* splitViewStorage.ts.
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, like the split-pane layout beside it, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "agenthub.workspace.laneOrder";
|
||||
|
||||
export function readLaneOrder(): number[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is number => typeof id === "number");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLaneOrder(ids: number[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a stored id order to a freshly fetched lane list.
|
||||
*
|
||||
* Ids the stored order never saw — a lane created since, or created in another
|
||||
* tab — keep their server order (id ascending) and land at the END, so a new
|
||||
* lane appears predictably rather than wherever a stale index puts it. Stored
|
||||
* ids naming a lane that no longer exists are simply skipped.
|
||||
*
|
||||
* @param lanes Lanes in server order.
|
||||
* @param order Stored id order.
|
||||
* @returns A new array; the input is never mutated.
|
||||
*/
|
||||
export function sortByStoredOrder<T extends { id: number }>(lanes: T[], order: number[]): T[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const known = lanes
|
||||
.filter((l) => rank.has(l.id))
|
||||
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
const fresh = lanes.filter((l) => !rank.has(l.id));
|
||||
return [...known, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the lane at `from` to index `to`, returning the resulting id order.
|
||||
* Out-of-range indices yield the unchanged order rather than a hole.
|
||||
*/
|
||||
export function moveLane(ids: number[], from: number, to: number): number[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids;
|
||||
const next = [...ids];
|
||||
next.splice(to, 0, ...next.splice(from, 1));
|
||||
return next;
|
||||
}
|
||||
@@ -2592,9 +2592,23 @@ export type LaneGitFacts =
|
||||
dirty: number;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||
files: LaneGitFile[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
|
||||
export interface LaneGitFile {
|
||||
/** Path relative to the repo root; for a rename, the new path. */
|
||||
path: string;
|
||||
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
|
||||
status: string;
|
||||
/** Lines added, or null when git has no delta for it (untracked, binary). */
|
||||
added: number | null;
|
||||
/** Lines removed, or null for the same reasons. */
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
@@ -141,6 +141,8 @@ import type {
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
@@ -4232,6 +4234,11 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`. Empty until asked for: reading it
|
||||
* refreshes that account's stored OAuth token, so it is never fetched with
|
||||
* the list — the user asks per profile, one profile at a time. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -4276,6 +4283,26 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setBusy(`usage:${name}`);
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -4441,6 +4468,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={busy === `usage:${p.name}`}
|
||||
disabled={busy !== null}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
@@ -4459,6 +4493,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
|
||||
+140
-89
@@ -2,8 +2,8 @@
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -42,20 +42,18 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
Lane,
|
||||
LaneFeature,
|
||||
LaneCounts,
|
||||
ProofFeature,
|
||||
Session,
|
||||
WSMessage,
|
||||
LaneTool,
|
||||
} from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, {
|
||||
@@ -72,6 +70,14 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||
* each, so the toggle reads at a glance without spending three words on it. */
|
||||
const LAYOUT_CHOICES = [
|
||||
{ n: 1 as const, Icon: Square },
|
||||
{ n: 2 as const, Icon: Columns2 },
|
||||
{ n: 4 as const, Icon: Grid2x2 },
|
||||
];
|
||||
|
||||
function SplitLayoutToggle({
|
||||
splitView,
|
||||
setLayout,
|
||||
@@ -83,21 +89,29 @@ function SplitLayoutToggle({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([1, 2, 4] as const).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{tLanes("splitView.paneCount", { count: n })}
|
||||
</button>
|
||||
))}
|
||||
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
||||
// The count still reaches assistive tech and the tooltip — only the
|
||||
// rendered word is gone, and the icon says the same thing in the
|
||||
// shape every other app uses for it.
|
||||
const label = tLanes("splitView.paneCount", { count: n });
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border p-1.5 ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +201,6 @@ export function Workspace() {
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
@@ -199,6 +212,9 @@ export function Workspace() {
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -246,8 +262,10 @@ export function Workspace() {
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
setCounts(keepIfSame(r.counts));
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -466,6 +484,37 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a lane card within the strip. Purely a viewer preference — it writes
|
||||
* localStorage, never the lane row, so nothing the CLI or another session
|
||||
* reads changes.
|
||||
*/
|
||||
const handleLaneReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
||||
const ids = moveLane(
|
||||
lanes.map((l) => l.id),
|
||||
from,
|
||||
to
|
||||
);
|
||||
writeLaneOrder(ids);
|
||||
setLanes(sortByStoredOrder(lanes, ids));
|
||||
};
|
||||
|
||||
/** Commit an inline lane rename. An empty name clears the title, which puts
|
||||
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
||||
const commitRename = async (id: number) => {
|
||||
const title = renameDraft.trim();
|
||||
setRenamingLaneId(null);
|
||||
const lane = lanes.find((l) => l.id === id);
|
||||
if (!lane || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
@@ -499,66 +548,33 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Page header: what this screen is, and the four numbers that say
|
||||
whether anything needs a human right now. */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
||||
{tLanes("title")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
data-testid="count-total"
|
||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
>
|
||||
{counts.total} {tLanes("countTotal")}
|
||||
</span>
|
||||
<span
|
||||
data-testid="count-running"
|
||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
>
|
||||
{counts.running} {tLanes("countRunning")}
|
||||
</span>
|
||||
{counts.needs_you > 0 && (
|
||||
<span
|
||||
data-testid="count-needs-you"
|
||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
||||
>
|
||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
||||
</span>
|
||||
)}
|
||||
{counts.dead > 0 && (
|
||||
<span
|
||||
data-testid="count-dead"
|
||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
||||
>
|
||||
{counts.dead} {tLanes("countDead")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
||||
<button
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
title={tLanes("addLane")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||
{lanes.map((l) => (
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
{!lanes.length && (
|
||||
@@ -597,15 +613,47 @@ export function Workspace() {
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
title={currentLane.cwd}
|
||||
>
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
})}
|
||||
</span>
|
||||
{/* 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 ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
@@ -674,6 +722,9 @@ export function Workspace() {
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* @file Workspace.laneReorderRename.test.tsx
|
||||
* @description Integration tests for lane strip reordering, persistence to
|
||||
* localStorage, and inline lane rename functionality. Covers the three paths
|
||||
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
|
||||
* commit/cancel/error paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string, payload?: unknown) => {
|
||||
recordedCalls.push({ method, path, payload });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return {
|
||||
lanes: lanesToReturn,
|
||||
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
|
||||
};
|
||||
}),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
runtime: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/runtime`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn(),
|
||||
pipelines: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes/pipelines");
|
||||
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
|
||||
recordCall("PATCH", `/api/lanes/${id}`, patch);
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
const updated = { ...lane, ...patch } as LaneFixture;
|
||||
// Update in place for subsequent reads
|
||||
const idx = lanesToReturn.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) lanesToReturn[idx] = updated;
|
||||
return { lane: updated };
|
||||
}),
|
||||
features: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/features`);
|
||||
return { features: [] };
|
||||
}),
|
||||
show: vi.fn(),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — lane reordering", () => {
|
||||
it("persists lane order to localStorage when dragging a tile", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
|
||||
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
|
||||
|
||||
// Drag tile 3 (Lane C) to position 0
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
const tile3 = screen.getByTestId("lane-tile-3");
|
||||
|
||||
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
|
||||
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
|
||||
fireEvent.drop(tile1, {
|
||||
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage has the new order
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
expect(stored).toBeDefined();
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("applies stored order when lanes are fetched", async () => {
|
||||
// Pre-populate localStorage with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The lane strip should reflect the stored order: B, C, A
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
expect(laneIds).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("appends new lanes to the end, preserving user order", async () => {
|
||||
// Start with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
|
||||
});
|
||||
|
||||
it("ignores stored ids for lanes that no longer exist", async () => {
|
||||
// Store an order that includes a lane ID that will be removed
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
|
||||
|
||||
// Remove lane 2 from the server response
|
||||
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The strip should show lane 1 and 3 in stored order, skipping 999
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
|
||||
expect(laneIds).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("reorders with Alt+Arrow keyboard shortcut", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Get tile 2 (Lane B) and move it right to position 2
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
// Tile 2 was at index 1, moving right puts it at index 2
|
||||
expect(order).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane rename", () => {
|
||||
it("enters rename mode when the pencil icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Click the first lane to select it (so its detail panel shows)
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Find the rename button (displays the lane title with a pencil icon)
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
// The input should appear with the current title
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("commits a rename when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Select lane 1 and open rename
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "New Lane Name");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should have called update with trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
|
||||
});
|
||||
|
||||
// Input should be gone, button should be back
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels rename when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.type(input, "should not save");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Input should be gone
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// No API call should have been made
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Move focus away (tab)
|
||||
await user.tab();
|
||||
await settle();
|
||||
|
||||
// Input should be gone (rename mode exited)
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// The rename button should be back
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trims whitespace from the renamed title", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, " Trimmed ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
// Leave it empty (or with just whitespace)
|
||||
await user.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send empty string
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API if the title has not changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// Don't change the title, just press Enter
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should NOT have called update (or at least not with a changed value)
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows the user to retry after an error, starting fresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Enter and exit rename mode twice to verify the state resets
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Enter again to verify we can re-enter
|
||||
await user.click(screen.getByTestId("lane-rename"));
|
||||
await settle();
|
||||
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("respects max length of 200 characters (enforced by server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// HTML input has maxLength=200, so typing more should be capped
|
||||
expect(input.maxLength).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane order and rename interaction", () => {
|
||||
it("preserves custom order after renaming a lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify order is C, B, A
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["3", "2", "1"]);
|
||||
|
||||
// Click lane 2 (which is now at position 1)
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
await user.click(tile2);
|
||||
await settle();
|
||||
|
||||
// Rename it
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed B");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Wait for the refresh
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Order should still be preserved
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file Workspace.laneRouter.test.tsx
|
||||
* @description The Model (AI Router) picker in the Workspace lane-detail
|
||||
* header, exercised through the page rather than through `LaneRouterRow` on its
|
||||
* own — one row instance serves every lane, so the bug this pins is a lane
|
||||
* switch carrying the previous lane's selection with it: pick a router on one
|
||||
* lane, come back to a lane that is on native, and see the router still
|
||||
* selected there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
router_profile: string | null;
|
||||
};
|
||||
|
||||
const lane = (id: number, title: string): LaneFixture => ({
|
||||
id,
|
||||
cwd: `/workspace/${id}`,
|
||||
title,
|
||||
pipeline: "default",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
router_profile: null,
|
||||
});
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
const nativeProfile = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
return {
|
||||
lanes: [] as { id: number; router_profile: string | null }[],
|
||||
routerApply: vi.fn(),
|
||||
profiles: [
|
||||
nativeProfile,
|
||||
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const routerApply = h.routerApply;
|
||||
let lanesToReturn: LaneFixture[] = [];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => ({
|
||||
lanes: h.lanes,
|
||||
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
|
||||
})),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockResolvedValue({ available: false }),
|
||||
runtime: vi.fn().mockResolvedValue({ available: false }),
|
||||
stage: vi.fn(),
|
||||
update: vi.fn(),
|
||||
pipelines: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
|
||||
// The route the picker must use — never a lane PATCH.
|
||||
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
|
||||
h.routerApply(id, profile);
|
||||
// The row moving with the call is the server's broadcast, not a test
|
||||
// convenience: the apply route broadcasts the lane so the cached row
|
||||
// stops disagreeing with what was just written. Drop the broadcast
|
||||
// server-side and the next lane switch re-reads a stale profile.
|
||||
const row = h.lanes.find((l) => l.id === id);
|
||||
if (row) row.router_profile = profile === "native" ? null : profile;
|
||||
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
|
||||
}),
|
||||
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
|
||||
proof: {
|
||||
list: vi.fn().mockResolvedValue({ features: [] }),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
routerApply.mockClear();
|
||||
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
|
||||
h.lanes = lanesToReturn as unknown as typeof h.lanes;
|
||||
});
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
/** Click the strip tile for a lane, which is how a lane is selected. */
|
||||
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
|
||||
const tile = screen.getAllByText(title)[0];
|
||||
if (!tile) throw new Error(`no strip tile for ${title}`);
|
||||
await user.click(tile);
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("Workspace — the lane Model picker across a lane switch", () => {
|
||||
it("leaves a lane on native after another lane was pointed at a router", async () => {
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectLane(user, "Lane A");
|
||||
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
|
||||
await selectLane(user, "Lane B");
|
||||
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
await settle();
|
||||
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
|
||||
|
||||
// Back to the lane that was never switched. It is still native server-side,
|
||||
// so the picker must say so — the row is one instance shared by every lane.
|
||||
await selectLane(user, "Lane A");
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
// And nothing may have been written to lane A on the way.
|
||||
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -328,19 +328,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists lanes and counters match the API response", async () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify lanes are rendered
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
|
||||
// Verify counters are rendered with correct values
|
||||
const counterTexts = screen.getAllByText((_, element) => {
|
||||
if (!element) return false;
|
||||
return element.textContent?.includes("2 lanes") || false;
|
||||
});
|
||||
expect(counterTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -497,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("shows the four counters with the values the API reported", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
||||
});
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
|
||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="space-y-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<h2
|
||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
||||
>
|
||||
Lanes
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
data-testid="count-total"
|
||||
>
|
||||
0
|
||||
|
||||
lanes
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
data-testid="count-running"
|
||||
>
|
||||
0
|
||||
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ml-auto flex items-center gap-2"
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
data-testid="lane-tile-add"
|
||||
title="Create a lane from a working directory"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-3.5 w-3.5"
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
@@ -5182,25 +5151,97 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
aria-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
1 pane
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
2 pane
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
4 pane
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -5208,13 +5249,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
data-testid="console-body"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5231,13 +5272,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-lg font-semibold text-fg-primary"
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
@@ -5251,9 +5292,9 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted max-w-3xl"
|
||||
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Spin up a Claude session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -5298,10 +5339,172 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
@@ -5341,168 +5544,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
@@ -9537,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.6.5 (rebuild client to match)
|
||||
UI build v1.6.10 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+55
-2
@@ -174,6 +174,19 @@ PATCH /api/lanes/:id
|
||||
Partially updates a lane. This route is same-origin guarded because its patch
|
||||
can set `run_id`.
|
||||
|
||||
Every patch value must be a **scalar** — a string, a number or `null`. An
|
||||
object, an array or a boolean for any column but `links` (which is stored as
|
||||
JSON and must be an object) is rejected with `400 EBADFIELD`, as is a `NOT
|
||||
NULL` column patched to `null`: such a value reaches SQLite as a datatype or
|
||||
constraint error, which Express could only report as a `500` on what is plainly
|
||||
the caller's mistake.
|
||||
|
||||
`title` is patchable, which is how the dashboard's inline rename sets a lane's
|
||||
display name. It is trimmed and capped at 200 characters, and `null` clears it
|
||||
rather than erroring — clearing a title is legitimate, since callers fall back
|
||||
to showing the lane's `cwd`. Renaming is display-only: a lane's branch name was
|
||||
slugified from its title once, at creation.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
@@ -349,6 +362,41 @@ dashboard row forgotten, and its directory is never changed. `400` preserves
|
||||
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
||||
|
||||
#### Read a lane's working copy
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git
|
||||
```
|
||||
|
||||
What the lane's directory looks like to git right now:
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||||
unstaged work together — and are `null` when git has no delta to report: an
|
||||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||||
and the lane list is polled and re-broadcast on every hook. See
|
||||
`docs/LANES.md` for the full treatment.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
```http
|
||||
@@ -1600,6 +1648,7 @@ GET /api/cc-config/profiles
|
||||
POST /api/cc-config/profiles Body: { name? }
|
||||
PATCH /api/cc-config/profiles Body: { name, label }
|
||||
POST /api/cc-config/profiles/activate Body: { name }
|
||||
POST /api/cc-config/profiles/usage Body: { name }
|
||||
DELETE /api/cc-config/profiles Body: { name }
|
||||
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
||||
DELETE /api/cc-config/file Body: { scope, type, name? }
|
||||
@@ -1629,7 +1678,7 @@ That indirection is structural, not stylistic: the plugin this dashboard *itself
|
||||
|
||||
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
|
||||
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
|
||||
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
|
||||
|
||||
@@ -1649,6 +1698,10 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
|
||||
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path. **Nothing calls this on a timer** — it is one profile per user request, which is what keeps credential rewrites down to the moments the user asked for. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
|
||||
|
||||
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
|
||||
|
||||
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
|
||||
@@ -1749,7 +1802,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
|
||||
|
||||
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
|
||||
|
||||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
|
||||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
|
||||
|
||||
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
|
||||
|
||||
|
||||
+44
-6
@@ -370,11 +370,15 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
@@ -442,7 +446,12 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "client/src/lib/api.ts", "status": "M ", "added": 1, "deleted": 0 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -451,10 +460,26 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
is a normal state, not a fault, and the card simply renders without a git row.
|
||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||
prettier invention.
|
||||
- `files` names the files the two counts came from, **capped at 20** — a lane
|
||||
mid-`npm install` would otherwise ship thousands of paths through a polled
|
||||
endpoint. The counts are never capped, so the panel that shows this list says
|
||||
`… and N more files` when it is showing less than all of it. Each entry
|
||||
carries its two-character `git status --porcelain` code (`status`, for a
|
||||
rename the `path` is the NEW one) and its line delta.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD`, which covers staged
|
||||
and unstaged work together. They are `null` when git has no delta to report:
|
||||
an **untracked** file (git has never seen it — its content is deliberately not
|
||||
read off disk, since a lane mid-build can have a 100 MB artifact), a **binary**
|
||||
file, a **rename** (numstat keys those differently), or a repo with no commit
|
||||
to diff against yet.
|
||||
- Hovering (or focusing, or tapping) the `N modified · N untracked` text on a
|
||||
lane card opens this list as a panel: one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
three times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
five times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
`lane_update`. Putting it there would fire a burst of subprocesses behind every
|
||||
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||
every 30 seconds, and a failed fetch is silent.
|
||||
@@ -574,6 +599,19 @@ contributes at most one record per node, however many times it ran — so this
|
||||
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
|
||||
`client/src/components/workflows/LanePipelinePanel.tsx`).
|
||||
|
||||
**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is
|
||||
the denominator every rate on that page is read against, so what counts as a run
|
||||
decides whether the page is worth reading. Stage detection bounces by design —
|
||||
one feature's work reads `implement → tests → implement → tests` as the session
|
||||
edits, runs the suite, and edits again — and when any backwards move opened a
|
||||
run, a single feature was recorded as five or six of them. A real pass ends
|
||||
where the session says it ends: a backwards move only starts a new run when it
|
||||
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
|
||||
detection that moves backwards continues the run in flight. Reaching the last
|
||||
node still closes the run for both — work after `done` is new work by anyone's
|
||||
reading. On this repo's own lane the rule took a 26-run history down to 5, which
|
||||
is the number of features that were actually shipped through it.
|
||||
|
||||
`?lane=<id>` narrows the same aggregation to one lane and its own archived
|
||||
features, which is what the panel's lane picker sends. The `lanes` array in the
|
||||
response is never narrowed — it populates that picker, so it has to keep
|
||||
@@ -617,7 +655,7 @@ agenthub stage implement --evidence "Schema created, migrations run"
|
||||
This command:
|
||||
- **Declares the stage** the work is now at
|
||||
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
||||
- **Moves the progress bar** forward in the pipeline
|
||||
- **Advances the pipeline map** to that node
|
||||
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
||||
|
||||
### Full command signature
|
||||
|
||||
+85
-4
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.6.5
|
||||
version: 1.6.10
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -3026,6 +3026,21 @@ components:
|
||||
example:
|
||||
- /Users/son/.claude/.credentials.json
|
||||
- /Users/son/.claude.json
|
||||
CcConfigUsageWindow:
|
||||
type: object
|
||||
nullable: true
|
||||
description: One quota window as the account reports it.
|
||||
properties:
|
||||
utilization:
|
||||
type: number
|
||||
nullable: true
|
||||
description: Percent of the window's allowance already spent, 0-100.
|
||||
example: 48
|
||||
resetsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: When the window rolls over, when the account reports one.
|
||||
CodexConfigScope:
|
||||
type: string
|
||||
enum:
|
||||
@@ -8044,7 +8059,7 @@ paths:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
operationId: ccConfigReload
|
||||
responses:
|
||||
'200':
|
||||
@@ -8979,6 +8994,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/profiles/usage:
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: How much of a saved profile's account quota is already spent
|
||||
description: |-
|
||||
Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
A POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.
|
||||
operationId: ccConfigProfileUsage
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: work
|
||||
responses:
|
||||
'200':
|
||||
description: Percentages and reset times for the two quota windows.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
refreshed:
|
||||
type: boolean
|
||||
description: True when the stored token was expired and had to be rotated.
|
||||
fiveHour:
|
||||
$ref: '#/components/schemas/CcConfigUsageWindow'
|
||||
sevenDay:
|
||||
$ref: '#/components/schemas/CcConfigUsageWindow'
|
||||
checkedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
'401':
|
||||
description: '`EUNAUTHORIZED` - the account refused the token even after a refresh.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: '`ENOTFOUND` - no profile by that name.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: '`EUPSTREAM` - Anthropic''s token or usage endpoint did not cooperate.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/codex-config/overview:
|
||||
get:
|
||||
tags:
|
||||
@@ -10690,7 +10771,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: A lane's working-copy facts
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
operationId: getLaneGitFacts
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -10700,7 +10781,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.10",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -160,7 +160,15 @@ function reloadPanes() {
|
||||
// a shell command instead.
|
||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||
try {
|
||||
let first = true;
|
||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
||||
// Let the first command actually submit before typing the second, or
|
||||
// both land on one composer line and Claude Code runs the pair as
|
||||
// `/reload-plugins /reload-skills` — see SETTLE_MS in
|
||||
// server/lib/cc-reload.js. Blocking is fine here: this worker is
|
||||
// detached and serves no requests.
|
||||
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
||||
first = false;
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
|
||||
@@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => {
|
||||
assert.deepEqual(backup, original);
|
||||
});
|
||||
|
||||
it("writes and backs up nothing when the patch changes nothing", () => {
|
||||
// Re-attaching the same AI Router profile sends the same eight env keys
|
||||
// every time. With an unconditional backup that left a `.bak` per apply
|
||||
// piling up inside the user's own project — a dozen untracked files in a
|
||||
// minute of switching lanes.
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
const before = fs.readFileSync(file, "utf8");
|
||||
|
||||
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
|
||||
assert.equal(result.backupPath, null);
|
||||
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
|
||||
assert.equal(fs.readFileSync(file, "utf8"), before);
|
||||
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
|
||||
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
|
||||
});
|
||||
|
||||
it("returns null backupPath for a fresh file", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
|
||||
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
it("types both reload commands, plugins before skills", async () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
@@ -119,27 +119,45 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
it("waits between the two commands so they cannot share one composer line", async () => {
|
||||
// Without the pause the first Enter lands on Claude Code's slash-command
|
||||
// menu, the second command is appended to the line still in the composer,
|
||||
// and the pane submits `/reload-plugins/reload-skills` as a prompt — the
|
||||
// reported bug. Timing is the behaviour, so timing is what is asserted.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
const at = [];
|
||||
const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length;
|
||||
const before = typed();
|
||||
const started = Date.now();
|
||||
const done = reloadOpenSessions({ settleMs: 40 });
|
||||
at.push(typed() - before);
|
||||
await done;
|
||||
|
||||
assert.equal(at[0], 1, "only the first command is typed before the wait");
|
||||
assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit");
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", async () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
it("ignores an unknown kind rather than typing it into a REPL", async () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
it("types into several lanes in one call", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
@@ -147,9 +165,9 @@ describe("reloadOpenSessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
it("records a pane that dies mid-send without failing the others", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
it("reports an empty result instead of throwing when nothing is open", async () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file cc-usage.test.js
|
||||
* @description Tests for per-profile quota lookup.
|
||||
*
|
||||
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
|
||||
* must be written back — the exchange retires the refresh token it was given,
|
||||
* so dropping the result leaves an account the user has to log into again. And
|
||||
* a still-valid token must NOT be refreshed, because every refresh rewrites
|
||||
* credentials on disk and the point of a read is to read.
|
||||
*
|
||||
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
|
||||
process.env.CLAUDE_HOME = TMP;
|
||||
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const usage = require("../lib/cc-usage");
|
||||
|
||||
const realFetch = global.fetch;
|
||||
let calls = [];
|
||||
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
||||
} catch {
|
||||
/* best-effort temp cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
function writeProfileFile(name, credentials) {
|
||||
const dir = profiles.profilesDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
name,
|
||||
label: "Label kept",
|
||||
savedAt: 1,
|
||||
account: { emailAddress: "a@b.c" },
|
||||
credentials,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer the token endpoint and the usage endpoint, recording both. */
|
||||
function stubFetch({ usageStatus = 200, usageBody } = {}) {
|
||||
global.fetch = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
access_token: "fresh-access",
|
||||
refresh_token: "rotated-refresh",
|
||||
expires_in: 28800,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: usageStatus === 200,
|
||||
status: usageStatus,
|
||||
json: async () =>
|
||||
usageBody || {
|
||||
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
|
||||
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
|
||||
seven_day_opus: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("profileUsage", () => {
|
||||
it("reports the two windows, and only those", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
|
||||
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
|
||||
// Nothing token-shaped may be in what a route is about to serialise.
|
||||
assert.equal(JSON.stringify(result).includes("still-good"), false);
|
||||
});
|
||||
|
||||
it("does not refresh a token that is still valid", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.equal(result.refreshed, false);
|
||||
assert.equal(
|
||||
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
||||
false,
|
||||
"a read must not rewrite credentials on disk"
|
||||
);
|
||||
assert.match(calls[0].init.headers.Authorization, /still-good/);
|
||||
});
|
||||
|
||||
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
|
||||
// The exchange retires the refresh token it was handed. A refresh whose
|
||||
// result is not persisted leaves the profile holding a dead credential —
|
||||
// the account could never be activated again without a fresh login.
|
||||
writeProfileFile("stale", {
|
||||
accessToken: "expired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() - 1000,
|
||||
subscriptionType: "team",
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("stale");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
const stored = profiles.readProfile("stale");
|
||||
assert.equal(stored.credentials.accessToken, "fresh-access");
|
||||
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
|
||||
assert.ok(stored.credentials.expiresAt > Date.now());
|
||||
// Everything else about the profile survives the rewrite.
|
||||
assert.equal(stored.label, "Label kept");
|
||||
assert.equal(stored.credentials.subscriptionType, "team");
|
||||
assert.equal(stored.account.emailAddress, "a@b.c");
|
||||
});
|
||||
|
||||
it("retries once through a refresh when a token that looked valid is refused", async () => {
|
||||
writeProfileFile("revoked", {
|
||||
accessToken: "revoked-but-unexpired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
let first = true;
|
||||
global.fetch = async (url) => {
|
||||
calls.push({ url: String(url) });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
|
||||
};
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
return { ok: false, status: 401, json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
|
||||
};
|
||||
};
|
||||
|
||||
const result = await usage.profileUsage("revoked");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
|
||||
});
|
||||
|
||||
it("refuses a profile with no refresh token instead of half-answering", async () => {
|
||||
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
|
||||
stubFetch();
|
||||
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("tokenless"),
|
||||
(e) => e.code === "ENOREFRESH"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unknown profile as ENOTFOUND", async () => {
|
||||
stubFetch();
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("nope"),
|
||||
(e) => e.code === "ENOTFOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,26 @@ describe("lane_stage_events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the run in flight when a DETECTION moves backwards", () => {
|
||||
// Detection bounces by design: one feature reads implement → tests →
|
||||
// implement as the session edits, runs the suite and edits again. Counting
|
||||
// each bounce as a new run recorded this repo's own lane as 26 features
|
||||
// when 5 had shipped, and `runs` is what every pass rate is divided by.
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
lanesLib.recordDetection(laneId, { nodeId: "tests", signal: "`npm test`" });
|
||||
// The bounce back only reaches the log once the standing detection has aged
|
||||
// past the TTL — forward-only holds while it is fresh (see recordDetection).
|
||||
db.prepare("UPDATE lanes SET detected_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run(
|
||||
laneId
|
||||
);
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
|
||||
assert.deepEqual(
|
||||
events().map((r) => `${r.node_id}#${r.run_seq}`),
|
||||
["implement#1", "tests#1", "implement#1"]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||
lanesLib.setStage(laneId, { stage: "done" });
|
||||
lanesLib.setStage(laneId, { stage: "intake" });
|
||||
|
||||
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
||||
});
|
||||
|
||||
// The Workspace's inline rename is a text field PATCHing this route, so a
|
||||
// title is the one lane column a user types directly. Bad input on any
|
||||
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
|
||||
// raises a datatype error Express can only report as a 500.
|
||||
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
|
||||
const id = created.body.lane.id;
|
||||
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
|
||||
assert.equal(named.status, 200);
|
||||
assert.equal(named.body.lane.title, "checkout flow");
|
||||
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
|
||||
assert.equal(cleared.body.lane.title, "");
|
||||
|
||||
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
|
||||
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
|
||||
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
}
|
||||
// A NOT NULL column patched to null is the caller's mistake too.
|
||||
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
|
||||
assert.equal(notNull.status, 400);
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("rejects cross-origin lane deletion", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
||||
const id = created.body.lane.id;
|
||||
@@ -253,6 +277,50 @@ describe("/api/lanes", () => {
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on router apply", async () => {
|
||||
// Without this the client's cached row keeps the OLD router_profile: the
|
||||
// picker looks right until the user switches lanes and back, at which
|
||||
// point the effect re-reads the stale row and flips the selection.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
|
||||
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
|
||||
const id = created.body.lane.id;
|
||||
// Start somewhere other than native, so the broadcast has to carry a
|
||||
// CHANGED value rather than the one the row already held.
|
||||
require("../lib/lanes").setRouterProfile(id, "some-router");
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(applied.status, 200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
|
||||
assert.ok(msg, "router apply should broadcast lane_update");
|
||||
// `native` is stored as null — what matters is that the pushed row is the
|
||||
// post-apply one, not the "some-router" the client still has cached.
|
||||
assert.equal(msg.data.lane.router_profile, null);
|
||||
} finally {
|
||||
// A socket left open by a failed assertion keeps the whole run alive.
|
||||
ws.close();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
|
||||
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
|
||||
assert.equal(after.tool, "codex");
|
||||
assert.equal(after.title, "x");
|
||||
});
|
||||
|
||||
// The Workspace's inline rename PATCHes `title` straight from a text field,
|
||||
// so the column is now reachable by anything same-origin. A non-string would
|
||||
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
|
||||
// plain bad input.
|
||||
it("normalizes a title on create and on update", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
|
||||
assert.equal(lane.title, "spaced");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
|
||||
// null clears rather than violating the NOT NULL, and the empty string is
|
||||
// a real value: it puts the lane's cwd back in the UI's place for it.
|
||||
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
|
||||
});
|
||||
|
||||
// Every free-text column, not just the one the rename field exposes: a
|
||||
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
|
||||
// only report as a 500 on what is plainly the caller's mistake.
|
||||
it("rejects a non-scalar patch value on any patchable column", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
|
||||
for (const patch of [
|
||||
{ title: { evil: true } },
|
||||
{ notes: { a: 1 } },
|
||||
{ branch: ["main"] },
|
||||
{ ci_status: true },
|
||||
{ session_id: () => "x" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => lanes.updateLane(lane.id, patch),
|
||||
(err) => err.code === "EBADFIELD",
|
||||
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
|
||||
(err) => err.code === "EBADFIELD"
|
||||
);
|
||||
// `links` is the one patchable column that IS an object — it is stored as
|
||||
// JSON, so it must keep working.
|
||||
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
|
||||
});
|
||||
});
|
||||
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
|
||||
@@ -100,6 +100,16 @@ describe("worktree", () => {
|
||||
assert.equal(s.dirty, 1);
|
||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||
// The files the counts came from, each with its porcelain status code and
|
||||
// its line delta. An UNTRACKED file has no delta to report — git has never
|
||||
// seen it — so its counts are null rather than invented.
|
||||
assert.deepEqual(
|
||||
s.files.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[
|
||||
{ path: "README.md", status: " M", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
]
|
||||
);
|
||||
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||
|
||||
@@ -571,6 +571,17 @@ function patchSettings(args) {
|
||||
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
|
||||
}
|
||||
|
||||
// A patch that changes nothing writes nothing — and therefore backs up
|
||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||
// file's content now, the request was satisfied, there was just nothing to
|
||||
// move to get there.
|
||||
if (existed && output === raw) {
|
||||
return { path: file, backupPath: null, created: false, changed };
|
||||
}
|
||||
|
||||
let backupPath = null;
|
||||
if (existed) {
|
||||
const root = settingsBackupRoot(scope, opts);
|
||||
|
||||
@@ -368,12 +368,26 @@ function deleteProfile(name) {
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which
|
||||
* has to write a rotated OAuth token pair straight back: the exchange retires
|
||||
* the refresh token it was given, so a profile whose refreshed credentials are
|
||||
* not persisted is a profile that can no longer be activated.
|
||||
*/
|
||||
function writeProfile(name, value) {
|
||||
writeJson(profileFile(name), value);
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// Token-bearing reads/writes: for cc-usage.js, never for a route.
|
||||
readProfile,
|
||||
writeProfile,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
+41
-12
@@ -29,6 +29,20 @@ const { listRuns } = require("./pty-run");
|
||||
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/**
|
||||
* Pause between two commands typed into the SAME pane.
|
||||
*
|
||||
* Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and
|
||||
* the Enter that follows lands on that menu rather than submitting the line.
|
||||
* With no pause the next command's text is appended to the line still sitting
|
||||
* in the composer, and what gets submitted is the single string
|
||||
* `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins`
|
||||
* with `/reload-skills` as its argument, so the second reload never happens and
|
||||
* the pane shows a stray prompt turn. Measured against a live pane: 0.3s is
|
||||
* already enough on an idle one, 1s leaves headroom for a busy REPL.
|
||||
*/
|
||||
const SETTLE_MS = 1000;
|
||||
|
||||
/** The only commands this module is ever allowed to type. Not caller-supplied:
|
||||
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||
const RELOAD_COMMANDS = {
|
||||
@@ -61,30 +75,39 @@ function reloadableLanes() {
|
||||
/**
|
||||
* Type the requested reload commands into every live `claude` pane.
|
||||
*
|
||||
* Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes
|
||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
||||
* sessions the user is actually working in running stale code.
|
||||
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
||||
* REPL's composer, so the command runs when the turn ends rather than being
|
||||
* lost. That is a deliberate choice — a reload that arrives late is still a
|
||||
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
||||
* user is actually working in running stale code. It is also why SETTLE_MS
|
||||
* exists: a composer that has not submitted yet is a line the next command
|
||||
* would be appended to.
|
||||
*
|
||||
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||
* must not fail a batch that updated everything else successfully.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
||||
* disk once the plugin has been reloaded).
|
||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}}
|
||||
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
||||
* separate submissions, and waiting for that without blocking the event loop is
|
||||
* the only way a live dashboard keeps broadcasting while it types.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
|
||||
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
||||
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
||||
* is a test seam — no route passes it.
|
||||
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}>}
|
||||
*/
|
||||
function reloadOpenSessions(options = {}) {
|
||||
async function reloadOpenSessions(options = {}) {
|
||||
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||
const lanes = [];
|
||||
|
||||
for (const lane of reloadableLanes()) {
|
||||
const sent = [];
|
||||
let error;
|
||||
for (const command of commands) {
|
||||
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
||||
try {
|
||||
tmux.sendText(lane.id, command);
|
||||
sent.push(command);
|
||||
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
|
||||
return { commands, lanes, lanes_total: lanes.length };
|
||||
}
|
||||
|
||||
module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS };
|
||||
module.exports = {
|
||||
reloadOpenSessions,
|
||||
reloadableLanes,
|
||||
RELOAD_COMMANDS,
|
||||
SHELL_COMMANDS,
|
||||
SETTLE_MS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file cc-usage.js
|
||||
* @description How much of an Anthropic account's quota is already spent —
|
||||
* the number that decides which saved auth profile (`cc-profiles.js`) is worth
|
||||
* switching to.
|
||||
*
|
||||
* There is no local source for it. Nothing under CLAUDE_HOME records a
|
||||
* utilization or a reset time; the credentials file carries only
|
||||
* `subscriptionType` and `rateLimitTier`. The figure comes from the same
|
||||
* endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for
|
||||
* whichever account's OAuth token is presented.
|
||||
*
|
||||
* Two rules this module exists to hold:
|
||||
*
|
||||
* - **A profile's token is sent to exactly one place: Anthropic.** It is read
|
||||
* from the profile file, put in an `Authorization` header, and never
|
||||
* returned, logged or echoed. What leaves this module is two percentages
|
||||
* and two reset timestamps.
|
||||
*
|
||||
* - **Nothing here runs on a poll.** A saved profile's access token is short
|
||||
* lived — every profile that is not the active one is normally expired — so
|
||||
* reading its usage means refreshing it first, and a refresh ROTATES the
|
||||
* stored refresh token. Doing that on a page load would rewrite the user's
|
||||
* credentials for every account they have, repeatedly, for a number on a
|
||||
* screen. It is therefore driven by an explicit per-profile request, and the
|
||||
* rotated tokens are written straight back (`cc-profiles.writeProfile`) —
|
||||
* a refresh whose result is dropped leaves the profile holding a refresh
|
||||
* token Anthropic has already retired, i.e. an account the user must log in
|
||||
* to again.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const profiles = require("./cc-profiles");
|
||||
|
||||
/** Claude Code's own public OAuth client. A refresh is only accepted for the
|
||||
* client the token was issued to, so this is not a configurable value. */
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
|
||||
/** Treat a token expiring within this window as already expired: a refresh is
|
||||
* cheaper than a 401 that costs the whole call. */
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw err("EUPSTREAM", `${url} did not answer JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a profile's refresh token for a fresh access token AND persist what
|
||||
* comes back.
|
||||
*
|
||||
* The persist is not an optimisation. Anthropic returns a new refresh token and
|
||||
* retires the one just used, so a refresh that is not written back leaves the
|
||||
* profile file holding a dead credential — the account would have to be logged
|
||||
* into again before it could ever be activated.
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<string>} the fresh access token (never returned to a route)
|
||||
*/
|
||||
async function refreshProfile(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
if (!creds.refreshToken) {
|
||||
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
||||
}
|
||||
|
||||
const data = await postJson(TOKEN_URL, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: creds.refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
||||
|
||||
profiles.writeProfile(name, {
|
||||
...stored,
|
||||
credentials: {
|
||||
...creds,
|
||||
accessToken: data.access_token,
|
||||
// Both are rotated by the exchange; keeping the old refresh token here
|
||||
// would pin the profile to a credential that no longer works.
|
||||
refreshToken: data.refresh_token || creds.refreshToken,
|
||||
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
||||
},
|
||||
});
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** The two windows the UI reports, flattened out of a response that also
|
||||
* carries a dozen experiment-named buckets this has no use for. */
|
||||
function windowOf(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const utilization = Number(value.utilization);
|
||||
return {
|
||||
utilization: Number.isFinite(utilization) ? utilization : null,
|
||||
resetsAt: typeof value.resets_at === "string" ? value.resets_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask Anthropic what this token's account has spent. */
|
||||
async function fetchUsage(accessToken) {
|
||||
const res = await fetch(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token");
|
||||
if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage for one saved profile, refreshing its token first when that token has
|
||||
* expired (which, for any profile that is not the active one, it usually has).
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null,
|
||||
* sevenDay:object|null, checkedAt:string}>} percentages and reset times only
|
||||
*/
|
||||
async function profileUsage(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
|
||||
let token = fresh ? creds.accessToken : await refreshProfile(name);
|
||||
let refreshed = !fresh;
|
||||
if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`);
|
||||
|
||||
try {
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
} catch (e) {
|
||||
// A token that looked fresh but was revoked server-side is worth one
|
||||
// retry through the refresh path; a second 401 is a real answer.
|
||||
if (e.code !== "EUNAUTHORIZED" || refreshed) throw e;
|
||||
token = await refreshProfile(name);
|
||||
refreshed = true;
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
|
||||
+63
-6
@@ -138,6 +138,28 @@ function hydrate(row) {
|
||||
return { ...row, stages, links, ports };
|
||||
}
|
||||
|
||||
/**
|
||||
* A lane's display name, as a column value. The title column is NOT NULL TEXT
|
||||
* and the dashboard's rename field writes it straight through, so it is
|
||||
* trimmed and capped — a pasted file is not a lane name. `null` clears the
|
||||
* title rather than violating the NOT NULL, because clearing is legitimate:
|
||||
* every render is `lane.title || lane.cwd`. A non-scalar is bad input and is
|
||||
* rejected the same way every other patch value is (see `bindable`).
|
||||
*
|
||||
* @param {unknown} value - Whatever the caller offered as a title.
|
||||
* @returns {string} A safe column value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
const TITLE_MAX = 200;
|
||||
function normalizeTitle(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
const t = typeof value;
|
||||
if (t !== "string" && t !== "number" && t !== "bigint") {
|
||||
throw Object.assign(new Error("title must be a string"), { code: "EBADFIELD" });
|
||||
}
|
||||
return String(value).trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
function createLane({
|
||||
title = "",
|
||||
cwd,
|
||||
@@ -161,7 +183,7 @@ function createLane({
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
normalizeTitle(title),
|
||||
cwd.replace(/\/+$/, ""),
|
||||
branch,
|
||||
pipeline,
|
||||
@@ -184,6 +206,31 @@ function getLane(id) {
|
||||
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch value, as something better-sqlite3 will actually bind.
|
||||
*
|
||||
* It binds numbers, strings, bigints, buffers and null — and THROWS on
|
||||
* anything else, including the `true` and the `{}` a hand-rolled PATCH can
|
||||
* send for any free-text lane column. That throw is indistinguishable from a
|
||||
* server fault by the time it reaches Express, so the route answered 500 to
|
||||
* what is plainly bad input. Rejecting here gives every PATCHABLE field the
|
||||
* one rule, rather than a special case per field the UI happens to expose.
|
||||
*
|
||||
* @param {string} key - The column being patched, for the error message.
|
||||
* @param {unknown} value - Whatever the caller sent for it.
|
||||
* @returns {string|number|bigint|null} A bindable value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
function bindable(key, value) {
|
||||
if (key === "title") return normalizeTitle(value);
|
||||
if (value === null || value === undefined) return null;
|
||||
const t = typeof value;
|
||||
if (t === "string" || t === "number" || t === "bigint") return value;
|
||||
throw Object.assign(new Error(`${key} must be a string, a number or null`), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
}
|
||||
|
||||
function updateLane(id, patch = {}) {
|
||||
// Validate kind before building the UPDATE if it's being set
|
||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
||||
@@ -197,7 +244,8 @@ function updateLane(id, patch = {}) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!PATCHABLE.has(k)) continue;
|
||||
cols.push(`${k} = ?`);
|
||||
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
|
||||
if (k === "links" && v !== null && typeof v === "object") vals.push(JSON.stringify(v));
|
||||
else vals.push(bindable(k, v));
|
||||
}
|
||||
if (cols.length) {
|
||||
cols.push("updated_at = ?");
|
||||
@@ -350,9 +398,17 @@ function reservedPorts(excludeLaneId = null) {
|
||||
*
|
||||
* The run a row belongs to is decided here rather than stored on the lane, so
|
||||
* there is one rule and no column to fall out of sync. A new run starts when
|
||||
* the lane moves BACKWARDS (the next feature begins at plan) or when the last
|
||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
||||
* other move continues the run in flight.
|
||||
* the lane is DECLARED backwards (the next feature begins at plan) or when the
|
||||
* last event sat on the pipeline's final node (the previous feature finished).
|
||||
* Any other move continues the run in flight.
|
||||
*
|
||||
* Only a declaration may open a run by going backwards. Detection bounces by
|
||||
* design — one feature reads `implement → tests → implement` as the session
|
||||
* edits, runs the suite and edits again — so counting every backwards detection
|
||||
* as a new run recorded a single feature as five or six of them, and `runs` is
|
||||
* the denominator the Workflows page reads every pass rate against. A pass ends
|
||||
* where the session says it ends. Reaching the final node still closes the run
|
||||
* for a detection too: work after `done` is new work however it was observed.
|
||||
*
|
||||
* Never throws: a lane's stage must still advance if this log cannot be
|
||||
* written — the dashboard losing a statistic beats an agent's pipeline dying.
|
||||
@@ -367,7 +423,8 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
if (last) {
|
||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
||||
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const declared = source !== "detected";
|
||||
const wentBack = declared && lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -105,18 +105,21 @@ function gitDirOf(laneDir) {
|
||||
* must not fail an otherwise-successful attach.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
const line = ".claude/settings.local.json";
|
||||
// The backup directory beside it, for the same reason plus one more: those
|
||||
// copies hold the API key too, and they are what a user sees as a dozen
|
||||
// untracked files appearing in their project while they switch routers.
|
||||
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
if (existing.split("\n").includes(line)) return;
|
||||
fs.writeFileSync(
|
||||
excludePath,
|
||||
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n`
|
||||
);
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* best effort — see above */
|
||||
}
|
||||
|
||||
+60
-4
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
||||
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||
const STATUS_FILE_CAP = 20;
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
@@ -546,14 +548,54 @@ async function removeWorktree(lane) {
|
||||
await deleteBranchSafely(sourceRepo, branch, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file line deltas against HEAD, keyed by path.
|
||||
*
|
||||
* One `git diff --numstat HEAD` covers staged AND unstaged work in a single
|
||||
* subprocess, which is the whole reason it is done this way: the alternative
|
||||
* is one process per file. It says nothing about UNTRACKED files — git has
|
||||
* never seen them, so they have no delta to report and their counts stay null
|
||||
* rather than being invented by reading the file off disk (a lane mid-build
|
||||
* can have an untracked 100 MB artifact).
|
||||
*
|
||||
* A repo with no commits yet has no HEAD to diff against; that throw is not a
|
||||
* fault, it just means every count is unknown.
|
||||
*/
|
||||
async function numstatByPath(dir) {
|
||||
const stats = new Map();
|
||||
try {
|
||||
const result = await git(dir, ["diff", "--numstat", "HEAD"]);
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
// A binary file reports "-" for both, not a number.
|
||||
if (m) {
|
||||
stats.set(m[3], {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no HEAD to diff against -> no per-file deltas */
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||
* Lines starting with ?? are untracked; others are dirty.
|
||||
*
|
||||
* `files` names WHICH files the counts mean, each with its porcelain status
|
||||
* code and its line delta, capped because this rides a polled endpoint and a
|
||||
* lane mid-`npm install` can report thousands. The COUNTS stay exact — only
|
||||
* the listing is capped.
|
||||
*/
|
||||
async function statusCounts(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const stats = await numstatByPath(dir);
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
const files = [];
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -562,29 +604,43 @@ async function statusCounts(dir) {
|
||||
} else {
|
||||
dirty++;
|
||||
}
|
||||
if (files.length < STATUS_FILE_CAP) {
|
||||
// "XY path", and for a rename "XY old -> new" — the new path is the one
|
||||
// that exists now and the one numstat is keyed by. git quotes a path
|
||||
// containing anything unusual; the quotes are presentation, not name.
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const path = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
files.push({
|
||||
path,
|
||||
status: line.slice(0, 2),
|
||||
added: null,
|
||||
deleted: null,
|
||||
...stats.get(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get short commit hash
|
||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||
const head = headResult.stdout.trim();
|
||||
|
||||
return { dirty, untracked, head };
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
*
|
||||
* Read-only and cheap, but it is three subprocesses, which is why it lives
|
||||
* Read-only and cheap, but it is five subprocesses, which is why it lives
|
||||
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
|
||||
* caller shows what git says rather than inventing a nicer word for it.
|
||||
*/
|
||||
async function gitFacts(dir) {
|
||||
const { dirty, untracked, head } = await statusCounts(dir);
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked };
|
||||
return { branch, head, subject, dirty, untracked, files };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1297,6 +1297,25 @@ const schemas = {
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigUsageWindow: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "One quota window as the account reports it.",
|
||||
properties: {
|
||||
utilization: {
|
||||
type: "number",
|
||||
nullable: true,
|
||||
description: "Percent of the window's allowance already spent, 0-100.",
|
||||
example: 48,
|
||||
},
|
||||
resetsAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
nullable: true,
|
||||
description: "When the window rolls over, when the account reports one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters (inlined per-operation to match base style) ────
|
||||
@@ -1634,7 +1653,7 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||
description:
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
operationId: "ccConfigReload",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -2649,6 +2668,74 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/profiles/usage": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "How much of a saved profile's account quota is already spent",
|
||||
description:
|
||||
"Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.\n\nA POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.",
|
||||
operationId: "ccConfigProfileUsage",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string", example: "work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Percentages and reset times for the two quota windows.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
refreshed: {
|
||||
type: "boolean",
|
||||
description: "True when the stored token was expired and had to be rotated.",
|
||||
},
|
||||
fiveHour: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
sevenDay: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
checkedAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "`EUNAUTHORIZED` - the account refused the token even after a refresh.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
502: {
|
||||
description: "`EUPSTREAM` - Anthropic's token or usage endpoint did not cooperate.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -268,13 +268,13 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's working-copy facts",
|
||||
description:
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
operationId: "getLaneGitFacts",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
||||
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
const ccMcpCli = require("../lib/cc-mcp-cli");
|
||||
const ccProfiles = require("../lib/cc-profiles");
|
||||
const ccUsage = require("../lib/cc-usage");
|
||||
const projectMemory = require("../lib/project-memory");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { broadcast } = require("../websocket");
|
||||
@@ -334,7 +335,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = reloadOpenSessions();
|
||||
reloaded = await reloadOpenSessions();
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
@@ -351,7 +352,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", (req, res) => {
|
||||
router.post("/reload", async (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
@@ -359,7 +360,7 @@ router.post("/reload", (req, res) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
||||
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
@@ -577,6 +578,31 @@ router.post("/profiles/activate", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /profiles/usage — how much of this profile's account quota is spent.
|
||||
*
|
||||
* A POST, and behind the same-origin guard, because it is not a read: a saved
|
||||
* profile's access token has almost always expired, so answering means
|
||||
* refreshing it, and the refresh rotates the stored credentials on disk (see
|
||||
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
|
||||
* per profile, when the user asks.
|
||||
*
|
||||
* The response is two percentages and two reset timestamps. No token material
|
||||
* crosses this boundary, in either direction: the name is all the caller sends.
|
||||
*/
|
||||
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
||||
const name = (req.body || {}).name;
|
||||
try {
|
||||
res.json(await ccUsage.profileUsage(name));
|
||||
} catch (e) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, EUNAUTHORIZED: 401 }[
|
||||
e.code
|
||||
] || 502;
|
||||
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
||||
router.delete("/profiles", (req, res) => {
|
||||
try {
|
||||
|
||||
+21
-6
@@ -412,7 +412,12 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
|
||||
if (
|
||||
err.code === "EBADCWD" ||
|
||||
err.code === "EBADPIPELINE" ||
|
||||
err.code === "EBADKIND" ||
|
||||
err.code === "EBADFIELD"
|
||||
) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
@@ -432,12 +437,18 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
||||
// fault — every sibling route answers 400 here, so this one must too
|
||||
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
|
||||
// here for the same reason: a NOT NULL column patched to null is the
|
||||
// caller's mistake, and enumerating which columns those are in JS would
|
||||
// just be the schema written twice.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
@@ -638,6 +649,10 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const result = applyRouterToLane(lane, profile);
|
||||
lanesLib.setRouterProfile(lane.id, profile);
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
broadcastLane(lane.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
@@ -690,7 +705,7 @@ router.get("/:id/integrations/:name", (req, res) => {
|
||||
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
||||
* exists for the destructive actions.
|
||||
*
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git five
|
||||
* times, and that payload is polled and re-broadcast on every hook-driven
|
||||
* lane_update. Any failure — no such directory, not a repo, git itself
|
||||
* erroring — is reported as `available: false` rather than a 500, because a
|
||||
|
||||
Reference in New Issue
Block a user