Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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.8 (2026-09-17) + 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 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 `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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,18 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { AiRouter } from "../../lib/api";
|
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:
|
/** 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. */
|
* change on the timescale the lane list is polled at. */
|
||||||
const GIT_REFRESH_MS = 30_000;
|
const GIT_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
@@ -379,6 +380,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({
|
export default function LaneCard({
|
||||||
lane,
|
lane,
|
||||||
onAction,
|
onAction,
|
||||||
@@ -399,6 +535,21 @@ export default function LaneCard({
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
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 git = useLaneGitFacts(lane.id);
|
||||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||||
const locks = useLaneLocks(lane.slot);
|
const locks = useLaneLocks(lane.slot);
|
||||||
@@ -530,18 +681,42 @@ export default function LaneCard({
|
|||||||
sentence rather than a four-row block. The cwd is NOT here — the
|
sentence rather than a four-row block. The cwd is NOT here — the
|
||||||
lane-detail header above this card already spells it out. */}
|
lane-detail header above this card already spells it out. */}
|
||||||
{git?.available && (
|
{git?.available && (
|
||||||
<div
|
<div className="mb-2">
|
||||||
data-testid="lane-git"
|
<div
|
||||||
className="mb-2 flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
data-testid="lane-git"
|
||||||
title={git.subject}
|
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="shrink-0">⑂ {git.branch}</span>
|
||||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||||
{(git.dirty > 0 || git.untracked > 0) && (
|
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||||
<span className="shrink-0 text-status-warning/80">
|
{(git.dirty > 0 || git.untracked > 0) && (
|
||||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
<button
|
||||||
</span>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,9 +7,15 @@
|
|||||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
* 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
|
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||||
* many lanes stay scannable in one scrolling row.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
|
||||||
@@ -45,33 +51,69 @@ function detectionLeads(lane: Lane): boolean {
|
|||||||
|
|
||||||
export default function LaneStripCard({
|
export default function LaneStripCard({
|
||||||
lane,
|
lane,
|
||||||
|
index,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onReorder,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
|
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||||
|
index: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onReorder: (from: number, to: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [dropTarget, setDropTarget] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`lane-tile-${lane.id}`}
|
data-testid={`lane-tile-${lane.id}`}
|
||||||
data-selected={selected ? "true" : undefined}
|
data-selected={selected ? "true" : undefined}
|
||||||
|
data-drop-target={dropTarget ? "true" : undefined}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
title={lane.cwd}
|
draggable
|
||||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
onDragStart={(e) => {
|
||||||
selected
|
// Firefox starts no drag at all without a setData call.
|
||||||
? "border-accent bg-accent/10"
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
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]">
|
<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={`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">
|
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||||
{t("cardId", { id: lane.id })}
|
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>
|
||||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||||
{lane.stage}
|
{lane.stage}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||||
@@ -207,6 +207,11 @@ describe("LaneCard git block", () => {
|
|||||||
subject: "free-text rule mode in the form",
|
subject: "free-text rule mode in the form",
|
||||||
dirty: 2,
|
dirty: 2,
|
||||||
untracked: 1,
|
untracked: 1,
|
||||||
|
files: [
|
||||||
|
{ 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 () => {
|
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||||
@@ -222,6 +227,57 @@ describe("LaneCard git block", () => {
|
|||||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
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 () => {
|
it("omits the uncommitted line when the tree is clean", async () => {
|
||||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||||
* AHEAD of the declared one, including when the declaration used a node alias
|
* declared one, including when the declaration used a node alias — which is
|
||||||
* (which is most of what the ship-feature skill declares).
|
* most of what the ship-feature skill declares), and the reorder handles.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import LaneStripCard from "../LaneStripCard";
|
import LaneStripCard from "../LaneStripCard";
|
||||||
import type { Lane, LaneNode } from "../../../lib/types";
|
import type { Lane, LaneNode } from "../../../lib/types";
|
||||||
|
|
||||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
|||||||
render(
|
render(
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
lane={lane({ detected_stage: "review" })}
|
lane={lane({ detected_stage: "review" })}
|
||||||
|
index={0}
|
||||||
selected={false}
|
selected={false}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
|
onReorder={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
|||||||
render(
|
render(
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||||
|
index={0}
|
||||||
selected={false}
|
selected={false}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
|
onReorder={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
|||||||
render(
|
render(
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||||
|
index={0}
|
||||||
selected={false}
|
selected={false}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
|
onReorder={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"worktrees.heading_other": "{{count}} worktrees",
|
"worktrees.heading_other": "{{count}} worktrees",
|
||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"autoStage": "auto: {{stage}}",
|
"autoStage": "auto: {{stage}}",
|
||||||
"cardId": "Lane {{id}}",
|
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||||
"confirmRemoveCancel": "Cancel",
|
"confirmRemoveCancel": "Cancel",
|
||||||
"confirmRemoveConfirm": "Remove",
|
"confirmRemoveConfirm": "Remove",
|
||||||
"confirmRemoveMessage": "This action cannot be undone.",
|
"confirmRemoveMessage": "This action cannot be undone.",
|
||||||
@@ -93,12 +93,17 @@
|
|||||||
"destructive.reset.title": "Reset managed worktree?",
|
"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.",
|
"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:",
|
"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",
|
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||||
"kind.adopted": "adopted",
|
"kind.adopted": "adopted",
|
||||||
"kind.managed": "managed",
|
"kind.managed": "managed",
|
||||||
"laneDetail.hide": "Hide details",
|
"laneDetail.hide": "Hide details",
|
||||||
"laneDetail.show": "Lane details",
|
"laneDetail.show": "Lane details",
|
||||||
"laneHeader": "Lane {{id}} · {{title}}",
|
"renameLabel": "Rename this lane",
|
||||||
"locks.held_one": "{{count}} lock held",
|
"locks.held_one": "{{count}} lock held",
|
||||||
"locks.held_other": "{{count}} locks held",
|
"locks.held_other": "{{count}} locks held",
|
||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"worktrees.heading_other": "{{count}} worktree",
|
"worktrees.heading_other": "{{count}} worktree",
|
||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"autoStage": "tự động: {{stage}}",
|
"autoStage": "tự động: {{stage}}",
|
||||||
"cardId": "Làn đường {{id}}",
|
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||||
"confirmRemoveCancel": "Hủy",
|
"confirmRemoveCancel": "Hủy",
|
||||||
"confirmRemoveConfirm": "Xóa",
|
"confirmRemoveConfirm": "Xóa",
|
||||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||||
@@ -93,12 +93,17 @@
|
|||||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
"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.",
|
"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:",
|
"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",
|
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||||
"kind.adopted": "đã nhận",
|
"kind.adopted": "đã nhận",
|
||||||
"kind.managed": "được quản lý",
|
"kind.managed": "được quản lý",
|
||||||
"laneDetail.hide": "Ẩn chi tiết",
|
"laneDetail.hide": "Ẩn chi tiết",
|
||||||
"laneDetail.show": "Chi tiết lane",
|
"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_one": "Đang giữ {{count}} khóa",
|
||||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||||
"moreActions": "Thêm hành động",
|
"moreActions": "Thêm hành động",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
dirty: number;
|
||||||
/** Files git has never seen. */
|
/** Files git has never seen. */
|
||||||
untracked: number;
|
untracked: number;
|
||||||
|
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||||
|
files: LaneGitFile[];
|
||||||
}
|
}
|
||||||
| { available: false };
|
| { 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. */
|
/** Aggregate counts across all lanes. */
|
||||||
export interface LaneCounts {
|
export interface LaneCounts {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
+109
-74
@@ -2,8 +2,8 @@
|
|||||||
* @file Workspace.tsx
|
* @file Workspace.tsx
|
||||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
* (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
|
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
* 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
|
* 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
|
* 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
|
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||||
@@ -42,20 +42,18 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||||
import type {
|
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||||
Lane,
|
|
||||||
LaneFeature,
|
|
||||||
LaneCounts,
|
|
||||||
ProofFeature,
|
|
||||||
Session,
|
|
||||||
WSMessage,
|
|
||||||
LaneTool,
|
|
||||||
} from "../lib/types";
|
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
|
import {
|
||||||
|
readLaneOrder,
|
||||||
|
writeLaneOrder,
|
||||||
|
sortByStoredOrder,
|
||||||
|
moveLane,
|
||||||
|
} from "../lib/laneOrderStorage";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
import LaneCard, {
|
import LaneCard, {
|
||||||
@@ -203,7 +201,6 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Lane state
|
// Lane state
|
||||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
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 [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||||
@@ -215,6 +212,9 @@ export function Workspace() {
|
|||||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
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
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
// lane strip itself rather than any one pane's form.
|
// lane strip itself rather than any one pane's form.
|
||||||
@@ -262,8 +262,10 @@ export function Workspace() {
|
|||||||
const refreshLanes = useCallback(async () => {
|
const refreshLanes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.list();
|
const r = await api.lanes.list();
|
||||||
setLanes(keepIfSame(r.lanes));
|
// The `lanes` state IS the display order: the server answers in id order,
|
||||||
setCounts(keepIfSame(r.counts));
|
// 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) =>
|
setSelectedLaneId((cur) =>
|
||||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||||
);
|
);
|
||||||
@@ -482,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
|
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||||
// stage-mismatch warning when the current declared stage matches no node
|
// stage-mismatch warning when the current declared stage matches no node
|
||||||
// in the newly chosen template.
|
// in the newly chosen template.
|
||||||
@@ -515,66 +548,33 @@ export function Workspace() {
|
|||||||
: "space-y-5"
|
: "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
|
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||||
scrolling row (holds any number of lanes), the detail/console area
|
scrolling row (holds any number of lanes), the detail/console area
|
||||||
below it gets the full width and never competes for it. */}
|
below it gets the full width and never competes for it. */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<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">
|
<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
|
<LaneStripCard
|
||||||
key={l.id}
|
key={l.id}
|
||||||
lane={l}
|
lane={l}
|
||||||
|
index={i}
|
||||||
selected={selectedLaneId === l.id}
|
selected={selectedLaneId === l.id}
|
||||||
onSelect={() => setSelectedLaneId(l.id)}
|
onSelect={() => setSelectedLaneId(l.id)}
|
||||||
|
onReorder={handleLaneReorder}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!lanes.length && (
|
{!lanes.length && (
|
||||||
@@ -613,15 +613,47 @@ export function Workspace() {
|
|||||||
? tLanes("statusDead")
|
? tLanes("statusDead")
|
||||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||||
</span>
|
</span>
|
||||||
<span
|
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
prefix any more: the id is on the strip tile, where the CLI's
|
||||||
title={currentLane.cwd}
|
`--lane` reader looks for it, and repeating it here only ate
|
||||||
>
|
the width the name needed. Submitting an empty name clears
|
||||||
{tLanes("laneHeader", {
|
`title`, which puts the cwd back in its place. */}
|
||||||
id: currentLane.id,
|
{renamingLaneId === currentLane.id ? (
|
||||||
title: currentLane.title || currentLane.cwd,
|
<input
|
||||||
})}
|
data-testid="lane-rename-input"
|
||||||
</span>
|
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">
|
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||||
{since(currentLane.stage_seconds)}
|
{since(currentLane.stage_seconds)}
|
||||||
</span>
|
</span>
|
||||||
@@ -690,6 +722,9 @@ export function Workspace() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-detail-toggle"
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -328,19 +328,11 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace page — lane integration", () => {
|
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();
|
await renderWorkspace();
|
||||||
|
|
||||||
// Verify lanes are rendered
|
|
||||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
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 () => {
|
it("selecting a lane switches the pipeline and run id", async () => {
|
||||||
@@ -497,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace layout", () => {
|
describe("Workspace layout", () => {
|
||||||
it("shows the four counters with the values the API reported", async () => {
|
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
const user = userEvent.setup();
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
|
||||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
const strip = screen.getByTestId("lane-strip");
|
||||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
await user.click(screen.getByTestId("lane-tile-add"));
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||||
await renderWorkspace();
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives every lane a tile in the carousel", async () => {
|
it("gives every lane a tile in the carousel", async () => {
|
||||||
|
|||||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<div
|
||||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||||
>
|
data-testid="lane-strip"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
title="Create a lane from a working directory"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-plus h-3.5 w-3.5"
|
class="lucide lucide-plus h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
Add lane
|
Add lane
|
||||||
</button>
|
</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
|
<p
|
||||||
class="text-sm text-fg-muted"
|
class="text-sm text-fg-muted"
|
||||||
>
|
>
|
||||||
@@ -9609,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
class="text-[10px] text-status-warning/90 mt-1"
|
||||||
>
|
>
|
||||||
UI build v1.6.6 (rebuild client to match)
|
UI build v1.6.9 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+49
-1
@@ -174,6 +174,19 @@ PATCH /api/lanes/:id
|
|||||||
Partially updates a lane. This route is same-origin guarded because its patch
|
Partially updates a lane. This route is same-origin guarded because its patch
|
||||||
can set `run_id`.
|
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
|
`pipeline` is patchable, which is how a lane moves between templates after
|
||||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
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
|
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||||||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
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
|
#### Read a lane's runtime
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1629,7 +1677,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.
|
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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
+41
-3
@@ -370,7 +370,11 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
|||||||
|
|
||||||
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||||
|
|
||||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||||
|
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. 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.
|
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||||
@@ -442,7 +446,12 @@ curl http://localhost:4820/api/lanes/5/git
|
|||||||
"head": "9b3e74a",
|
"head": "9b3e74a",
|
||||||
"subject": "free-text rule mode in the form",
|
"subject": "free-text rule mode in the form",
|
||||||
"dirty": 2,
|
"dirty": 2,
|
||||||
"untracked": 1
|
"untracked": 1,
|
||||||
|
"files": [
|
||||||
|
{ "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.
|
is a normal state, not a fault, and the card simply renders without a git row.
|
||||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||||
prettier invention.
|
prettier invention.
|
||||||
|
- `files` 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`.
|
- An unknown lane id is a `404`.
|
||||||
|
|
||||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||||
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
|
`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
|
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||||
every 30 seconds, and a failed fetch is silent.
|
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
|
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`).
|
`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
|
`?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
|
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
|
response is never narrowed — it populates that picker, so it has to keep
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 1.6.6
|
version: 1.6.9
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -8044,7 +8044,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- CcConfig
|
- CcConfig
|
||||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
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
|
operationId: ccConfigReload
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@@ -10690,7 +10690,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Lanes
|
- Lanes
|
||||||
summary: A lane's working-copy facts
|
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
|
operationId: getLaneGitFacts
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -10700,7 +10700,7 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files 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':
|
'404':
|
||||||
description: Lane not found.
|
description: Lane not found.
|
||||||
/api/lanes/{id}/runtime:
|
/api/lanes/{id}/runtime:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.6",
|
"version": "1.6.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.6",
|
"version": "1.6.9",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.6",
|
"version": "1.6.9",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,7 +160,15 @@ function reloadPanes() {
|
|||||||
// a shell command instead.
|
// a shell command instead.
|
||||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||||
try {
|
try {
|
||||||
|
let first = true;
|
||||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
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, "-l", command], { timeout: 15_000 });
|
||||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
|
|||||||
describe("reloadOpenSessions", () => {
|
describe("reloadOpenSessions", () => {
|
||||||
beforeEach(() => tmux.__reset());
|
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
|
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||||
// plugin itself has been reloaded.
|
// plugin itself has been reloaded.
|
||||||
const calls = stubTmux(["agenthub-lane-7"]);
|
const calls = stubTmux(["agenthub-lane-7"]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
|
|
||||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||||
assert.equal(out.lanes_total, 1);
|
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);
|
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 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"]);
|
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||||
assert.deepEqual(typed, ["/reload-skills"]);
|
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
|
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||||
// forward caller-supplied strings.
|
// forward caller-supplied strings.
|
||||||
const calls = stubTmux(["agenthub-lane-1"]);
|
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"]);
|
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
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"]);
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
assert.equal(out.lanes_total, 2);
|
assert.equal(out.lanes_total, 2);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
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__" });
|
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 failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||||
assert.match(failed.error, /died mid-send/);
|
assert.match(failed.error, /died mid-send/);
|
||||||
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
|
|||||||
assert.equal(ok.error, undefined);
|
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([]);
|
stubTmux([]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
assert.equal(out.lanes_total, 0);
|
assert.equal(out.lanes_total, 0);
|
||||||
assert.deepEqual(out.lanes, []);
|
assert.deepEqual(out.lanes, []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||||
lanesLib.setStage(laneId, { stage: "done" });
|
lanesLib.setStage(laneId, { stage: "done" });
|
||||||
lanesLib.setStage(laneId, { stage: "intake" });
|
lanesLib.setStage(laneId, { stage: "intake" });
|
||||||
|
|||||||
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
|
|||||||
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
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 () => {
|
it("rejects cross-origin lane deletion", async () => {
|
||||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
||||||
const id = created.body.lane.id;
|
const id = created.body.lane.id;
|
||||||
|
|||||||
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
|
|||||||
assert.equal(after.tool, "codex");
|
assert.equal(after.tool, "codex");
|
||||||
assert.equal(after.title, "x");
|
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");
|
const { withLaneLock } = require("../lib/lane-lock");
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ describe("worktree", () => {
|
|||||||
assert.equal(s.dirty, 1);
|
assert.equal(s.dirty, 1);
|
||||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||||
|
// The 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
|
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||||
|
|||||||
+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. */
|
/** 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"]);
|
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:
|
/** 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. */
|
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||||
const RELOAD_COMMANDS = {
|
const RELOAD_COMMANDS = {
|
||||||
@@ -61,30 +75,39 @@ function reloadableLanes() {
|
|||||||
/**
|
/**
|
||||||
* Type the requested reload commands into every live `claude` pane.
|
* 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
|
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
||||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
* REPL's composer, so the command runs when the turn ends rather than being
|
||||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
* lost. That is a deliberate choice — a reload that arrives late is still a
|
||||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
||||||
* sessions the user is actually working in running stale code.
|
* 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)
|
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||||
* must not fail a batch that updated everything else successfully.
|
* must not fail a batch that updated everything else successfully.
|
||||||
*
|
*
|
||||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
||||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
* separate submissions, and waiting for that without blocking the event loop is
|
||||||
* disk once the plugin has been reloaded).
|
* the only way a live dashboard keeps broadcasting while it types.
|
||||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
*
|
||||||
* commands: string[], lanes_total: number}}
|
* @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 kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||||
|
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||||
const lanes = [];
|
const lanes = [];
|
||||||
|
|
||||||
for (const lane of reloadableLanes()) {
|
for (const lane of reloadableLanes()) {
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let error;
|
let error;
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
|
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
||||||
try {
|
try {
|
||||||
tmux.sendText(lane.id, command);
|
tmux.sendText(lane.id, command);
|
||||||
sent.push(command);
|
sent.push(command);
|
||||||
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
|
|||||||
return { commands, lanes, lanes_total: lanes.length };
|
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,
|
||||||
|
};
|
||||||
|
|||||||
+63
-6
@@ -138,6 +138,28 @@ function hydrate(row) {
|
|||||||
return { ...row, stages, links, ports };
|
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({
|
function createLane({
|
||||||
title = "",
|
title = "",
|
||||||
cwd,
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
title,
|
normalizeTitle(title),
|
||||||
cwd.replace(/\/+$/, ""),
|
cwd.replace(/\/+$/, ""),
|
||||||
branch,
|
branch,
|
||||||
pipeline,
|
pipeline,
|
||||||
@@ -184,6 +206,31 @@ function getLane(id) {
|
|||||||
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(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 = {}) {
|
function updateLane(id, patch = {}) {
|
||||||
// Validate kind before building the UPDATE if it's being set
|
// Validate kind before building the UPDATE if it's being set
|
||||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
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)) {
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
if (!PATCHABLE.has(k)) continue;
|
if (!PATCHABLE.has(k)) continue;
|
||||||
cols.push(`${k} = ?`);
|
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) {
|
if (cols.length) {
|
||||||
cols.push("updated_at = ?");
|
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
|
* 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
|
* 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
|
* the lane is DECLARED backwards (the next feature begins at plan) or when the
|
||||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
* last event sat on the pipeline's final node (the previous feature finished).
|
||||||
* other move continues the run in flight.
|
* 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
|
* 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.
|
* 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) {
|
if (last) {
|
||||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
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;
|
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+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 LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
||||||
|
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||||
|
const STATUS_FILE_CAP = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||||
@@ -546,14 +548,54 @@ async function removeWorktree(lane) {
|
|||||||
await deleteBranchSafely(sourceRepo, branch, baseBranch);
|
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.
|
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||||
* Lines starting with ?? are untracked; others are dirty.
|
* Lines starting with ?? are untracked; others are dirty.
|
||||||
|
*
|
||||||
|
* `files` 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) {
|
async function statusCounts(dir) {
|
||||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||||
|
const stats = await numstatByPath(dir);
|
||||||
let dirty = 0;
|
let dirty = 0;
|
||||||
let untracked = 0;
|
let untracked = 0;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
for (const line of result.stdout.split("\n")) {
|
for (const line of result.stdout.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@@ -562,29 +604,43 @@ async function statusCounts(dir) {
|
|||||||
} else {
|
} else {
|
||||||
dirty++;
|
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
|
// Get short commit hash
|
||||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||||
const head = headResult.stdout.trim();
|
const head = headResult.stdout.trim();
|
||||||
|
|
||||||
return { dirty, untracked, head };
|
return { dirty, untracked, head, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a lane's working copy looks like right now: which branch it is on, the
|
* 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.
|
* 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`
|
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||||
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
|
* 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.
|
* caller shows what git says rather than inventing a nicer word for it.
|
||||||
*/
|
*/
|
||||||
async function gitFacts(dir) {
|
async function gitFacts(dir) {
|
||||||
const { dirty, untracked, head } = await statusCounts(dir);
|
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||||
return { branch, head, subject, dirty, untracked };
|
return { branch, head, subject, dirty, untracked, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1634,7 +1634,7 @@ const paths = {
|
|||||||
tags: ["CcConfig"],
|
tags: ["CcConfig"],
|
||||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||||
description:
|
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",
|
operationId: "ccConfigReload",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
|
|||||||
@@ -268,13 +268,13 @@ const paths = {
|
|||||||
tags: ["Lanes"],
|
tags: ["Lanes"],
|
||||||
summary: "A lane's working-copy facts",
|
summary: "A lane's working-copy facts",
|
||||||
description:
|
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",
|
operationId: "getLaneGitFacts",
|
||||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description:
|
description:
|
||||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files 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." },
|
404: { description: "Lane not found." },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ router.post("/plugins/action", async (req, res) => {
|
|||||||
let reloaded;
|
let reloaded;
|
||||||
if (reload && result.ok) {
|
if (reload && result.ok) {
|
||||||
try {
|
try {
|
||||||
reloaded = reloadOpenSessions();
|
reloaded = await reloadOpenSessions();
|
||||||
} catch {
|
} catch {
|
||||||
/* a reload that cannot be delivered must not fail the update itself */
|
/* a reload that cannot be delivered must not fail the update itself */
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@ router.post("/plugins/action", async (req, res) => {
|
|||||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||||
* mutation chain nor emits `cc_config_changed`.
|
* mutation chain nor emits `cc_config_changed`.
|
||||||
*/
|
*/
|
||||||
router.post("/reload", (req, res) => {
|
router.post("/reload", async (req, res) => {
|
||||||
const { kinds } = req.body || {};
|
const { kinds } = req.body || {};
|
||||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -359,7 +359,7 @@ router.post("/reload", (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||||
|
|||||||
+17
-6
@@ -412,7 +412,12 @@ router.post("/", sameOriginGuard, (req, res) => {
|
|||||||
broadcastLane(lane.id);
|
broadcastLane(lane.id);
|
||||||
res.status(201).json({ lane: payload(lane) });
|
res.status(201).json({ lane: payload(lane) });
|
||||||
} catch (err) {
|
} 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 } });
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||||
@@ -432,12 +437,18 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
||||||
// sibling route answers 400 here, so this one must too instead of throwing
|
// fault — every sibling route answers 400 here, so this one must too
|
||||||
// into Express.
|
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
|
||||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
// 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 } });
|
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 } });
|
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
broadcastLane(lane.id);
|
broadcastLane(lane.id);
|
||||||
@@ -690,7 +701,7 @@ router.get("/:id/integrations/:name", (req, res) => {
|
|||||||
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
||||||
* exists for the destructive actions.
|
* 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
|
* 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
|
* lane_update. Any failure — no such directory, not a repo, git itself
|
||||||
* erroring — is reported as `available: false` rather than a 500, because a
|
* erroring — is reported as `available: false` rather than a 500, because a
|
||||||
|
|||||||
Reference in New Issue
Block a user