Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -25,7 +25,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `agenthub stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
|
||||
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||
@@ -38,6 +38,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||||
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/<name>.config.toml` — the layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
|
||||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
|
||||
- **A lane's card position is a viewer preference; its name is a lane fact.** The Workspace strip's drag/Alt-arrow order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
|
||||
@@ -19,17 +19,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* each call is five git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
@@ -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({
|
||||
lane,
|
||||
onAction,
|
||||
@@ -399,6 +535,21 @@ export default function LaneCard({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
@@ -530,18 +681,42 @@ export default function LaneCard({
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="mb-2 flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<span className="shrink-0 text-status-warning/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</span>
|
||||
<div className="mb-2">
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
*
|
||||
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
||||
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
||||
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
||||
* the keyboard (and touch-free) path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
@@ -45,33 +51,69 @@ function detectionLeads(lane: Lane): boolean {
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
onReorder,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("reorderHint")}`}
|
||||
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||
width and said nothing, but the number itself is how the CLI
|
||||
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
@@ -207,6 +207,11 @@ describe("LaneCard git block", () => {
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
@@ -222,6 +227,57 @@ describe("LaneCard git block", () => {
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
||||
* AHEAD of the declared one, including when the declaration used a node alias
|
||||
* (which is most of what the ship-feature skill declares).
|
||||
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||
* declared one, including when the declaration used a node alias — which is
|
||||
* most of what the ship-feature skill declares), and the reorder handles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LaneStripCard from "../LaneStripCard";
|
||||
import type { Lane, LaneNode } from "../../../lib/types";
|
||||
|
||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneStripCard reorder", () => {
|
||||
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
|
||||
function dataTransfer(initial = "") {
|
||||
let payload = initial;
|
||||
return {
|
||||
setData: (_type: string, v: string) => {
|
||||
payload = v;
|
||||
},
|
||||
getData: () => payload,
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
};
|
||||
}
|
||||
|
||||
it("reports the dragged tile's index and the drop target's", () => {
|
||||
const onReorder = vi.fn();
|
||||
const dt = dataTransfer();
|
||||
render(
|
||||
<>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 1 })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 2 })}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
|
||||
expect(onReorder).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it("ignores a drop carrying no tile index", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-1");
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 1);
|
||||
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 3);
|
||||
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft" });
|
||||
expect(onReorder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the id without the 'Lane' prefix", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 7, title: "checkout flow" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-7");
|
||||
expect(tile).toHaveTextContent("#7");
|
||||
expect(tile.textContent).not.toMatch(/Lane/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"cardId": "Lane {{id}}",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
@@ -93,12 +93,17 @@
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"git.filesHeading_one": "{{count}} file changed",
|
||||
"git.filesHeading_other": "{{count}} files changed",
|
||||
"git.filesMore_one": "… and {{count}} more file",
|
||||
"git.filesMore_other": "… and {{count}} more files",
|
||||
"git.noDelta": "new",
|
||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}}",
|
||||
"renameLabel": "Rename this lane",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"moreActions": "More actions",
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"cardId": "Làn đường {{id}}",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
@@ -93,12 +93,17 @@
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"git.filesHeading_one": "{{count}} tệp thay đổi",
|
||||
"git.filesHeading_other": "{{count}} tệp thay đổi",
|
||||
"git.filesMore_one": "… và {{count}} tệp nữa",
|
||||
"git.filesMore_other": "… và {{count}} tệp nữa",
|
||||
"git.noDelta": "mới",
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}}",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"moreActions": "Thêm hành động",
|
||||
|
||||
@@ -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;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||
files: LaneGitFile[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
|
||||
export interface LaneGitFile {
|
||||
/** Path relative to the repo root; for a rename, the new path. */
|
||||
path: string;
|
||||
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
|
||||
status: string;
|
||||
/** Lines added, or null when git has no delta for it (untracked, binary). */
|
||||
added: number | null;
|
||||
/** Lines removed, or null for the same reasons. */
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
+109
-74
@@ -2,8 +2,8 @@
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -42,20 +42,18 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
Lane,
|
||||
LaneFeature,
|
||||
LaneCounts,
|
||||
ProofFeature,
|
||||
Session,
|
||||
WSMessage,
|
||||
LaneTool,
|
||||
} from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, {
|
||||
@@ -203,7 +201,6 @@ export function Workspace() {
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
@@ -215,6 +212,9 @@ export function Workspace() {
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -262,8 +262,10 @@ export function Workspace() {
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
setCounts(keepIfSame(r.counts));
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -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
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
@@ -515,66 +548,33 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Page header: what this screen is, and the four numbers that say
|
||||
whether anything needs a human right now. */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
||||
{tLanes("title")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
data-testid="count-total"
|
||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
>
|
||||
{counts.total} {tLanes("countTotal")}
|
||||
</span>
|
||||
<span
|
||||
data-testid="count-running"
|
||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
>
|
||||
{counts.running} {tLanes("countRunning")}
|
||||
</span>
|
||||
{counts.needs_you > 0 && (
|
||||
<span
|
||||
data-testid="count-needs-you"
|
||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
||||
>
|
||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
||||
</span>
|
||||
)}
|
||||
{counts.dead > 0 && (
|
||||
<span
|
||||
data-testid="count-dead"
|
||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
||||
>
|
||||
{counts.dead} {tLanes("countDead")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
||||
<button
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
title={tLanes("addLane")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||
{lanes.map((l) => (
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
{!lanes.length && (
|
||||
@@ -613,15 +613,47 @@ export function Workspace() {
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
title={currentLane.cwd}
|
||||
>
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
})}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
@@ -690,6 +722,9 @@ export function Workspace() {
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* @file Workspace.laneReorderRename.test.tsx
|
||||
* @description Integration tests for lane strip reordering, persistence to
|
||||
* localStorage, and inline lane rename functionality. Covers the three paths
|
||||
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
|
||||
* commit/cancel/error paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string, payload?: unknown) => {
|
||||
recordedCalls.push({ method, path, payload });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return {
|
||||
lanes: lanesToReturn,
|
||||
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
|
||||
};
|
||||
}),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
runtime: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/runtime`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn(),
|
||||
pipelines: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes/pipelines");
|
||||
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
|
||||
recordCall("PATCH", `/api/lanes/${id}`, patch);
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
const updated = { ...lane, ...patch } as LaneFixture;
|
||||
// Update in place for subsequent reads
|
||||
const idx = lanesToReturn.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) lanesToReturn[idx] = updated;
|
||||
return { lane: updated };
|
||||
}),
|
||||
features: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/features`);
|
||||
return { features: [] };
|
||||
}),
|
||||
show: vi.fn(),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — lane reordering", () => {
|
||||
it("persists lane order to localStorage when dragging a tile", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
|
||||
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
|
||||
|
||||
// Drag tile 3 (Lane C) to position 0
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
const tile3 = screen.getByTestId("lane-tile-3");
|
||||
|
||||
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
|
||||
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
|
||||
fireEvent.drop(tile1, {
|
||||
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage has the new order
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
expect(stored).toBeDefined();
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("applies stored order when lanes are fetched", async () => {
|
||||
// Pre-populate localStorage with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The lane strip should reflect the stored order: B, C, A
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
expect(laneIds).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("appends new lanes to the end, preserving user order", async () => {
|
||||
// Start with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
|
||||
});
|
||||
|
||||
it("ignores stored ids for lanes that no longer exist", async () => {
|
||||
// Store an order that includes a lane ID that will be removed
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
|
||||
|
||||
// Remove lane 2 from the server response
|
||||
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The strip should show lane 1 and 3 in stored order, skipping 999
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
|
||||
expect(laneIds).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("reorders with Alt+Arrow keyboard shortcut", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Get tile 2 (Lane B) and move it right to position 2
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
// Tile 2 was at index 1, moving right puts it at index 2
|
||||
expect(order).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane rename", () => {
|
||||
it("enters rename mode when the pencil icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Click the first lane to select it (so its detail panel shows)
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Find the rename button (displays the lane title with a pencil icon)
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
// The input should appear with the current title
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("commits a rename when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Select lane 1 and open rename
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "New Lane Name");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should have called update with trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
|
||||
});
|
||||
|
||||
// Input should be gone, button should be back
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels rename when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.type(input, "should not save");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Input should be gone
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// No API call should have been made
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Move focus away (tab)
|
||||
await user.tab();
|
||||
await settle();
|
||||
|
||||
// Input should be gone (rename mode exited)
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// The rename button should be back
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trims whitespace from the renamed title", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, " Trimmed ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
// Leave it empty (or with just whitespace)
|
||||
await user.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send empty string
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API if the title has not changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// Don't change the title, just press Enter
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should NOT have called update (or at least not with a changed value)
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows the user to retry after an error, starting fresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Enter and exit rename mode twice to verify the state resets
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Enter again to verify we can re-enter
|
||||
await user.click(screen.getByTestId("lane-rename"));
|
||||
await settle();
|
||||
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("respects max length of 200 characters (enforced by server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// HTML input has maxLength=200, so typing more should be capped
|
||||
expect(input.maxLength).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane order and rename interaction", () => {
|
||||
it("preserves custom order after renaming a lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify order is C, B, A
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["3", "2", "1"]);
|
||||
|
||||
// Click lane 2 (which is now at position 1)
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
await user.click(tile2);
|
||||
await settle();
|
||||
|
||||
// Rename it
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed B");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Wait for the refresh
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Order should still be preserved
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -328,19 +328,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists lanes and counters match the API response", async () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify lanes are rendered
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
|
||||
// Verify counters are rendered with correct values
|
||||
const counterTexts = screen.getAllByText((_, element) => {
|
||||
if (!element) return false;
|
||||
return element.textContent?.includes("2 lanes") || false;
|
||||
});
|
||||
expect(counterTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -497,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("shows the four counters with the values the API reported", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
||||
});
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
|
||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="space-y-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<h2
|
||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
||||
>
|
||||
Lanes
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
data-testid="count-total"
|
||||
>
|
||||
0
|
||||
|
||||
lanes
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
data-testid="count-running"
|
||||
>
|
||||
0
|
||||
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ml-auto flex items-center gap-2"
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
data-testid="lane-tile-add"
|
||||
title="Create a lane from a working directory"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-3.5 w-3.5"
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
@@ -9609,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.6.6 (rebuild client to match)
|
||||
UI build v1.6.8 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+48
@@ -174,6 +174,19 @@ PATCH /api/lanes/:id
|
||||
Partially updates a lane. This route is same-origin guarded because its patch
|
||||
can set `run_id`.
|
||||
|
||||
Every patch value must be a **scalar** — a string, a number or `null`. An
|
||||
object, an array or a boolean for any column but `links` (which is stored as
|
||||
JSON and must be an object) is rejected with `400 EBADFIELD`, as is a `NOT
|
||||
NULL` column patched to `null`: such a value reaches SQLite as a datatype or
|
||||
constraint error, which Express could only report as a `500` on what is plainly
|
||||
the caller's mistake.
|
||||
|
||||
`title` is patchable, which is how the dashboard's inline rename sets a lane's
|
||||
display name. It is trimmed and capped at 200 characters, and `null` clears it
|
||||
rather than erroring — clearing a title is legitimate, since callers fall back
|
||||
to showing the lane's `cwd`. Renaming is display-only: a lane's branch name was
|
||||
slugified from its title once, at creation.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
@@ -349,6 +362,41 @@ dashboard row forgotten, and its directory is never changed. `400` preserves
|
||||
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
||||
|
||||
#### Read a lane's working copy
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git
|
||||
```
|
||||
|
||||
What the lane's directory looks like to git right now:
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||||
unstaged work together — and are `null` when git has no delta to report: an
|
||||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||||
and the lane list is polled and re-broadcast on every hook. See
|
||||
`docs/LANES.md` for the full treatment.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
```http
|
||||
|
||||
+28
-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:
|
||||
|
||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||
- **Split view** — a layout toggle (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",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "client/src/lib/api.ts", "status": "M ", "added": 1, "deleted": 0 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -451,10 +460,26 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
is a normal state, not a fault, and the card simply renders without a git row.
|
||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||
prettier invention.
|
||||
- `files` names the files the two counts came from, **capped at 20** — a lane
|
||||
mid-`npm install` would otherwise ship thousands of paths through a polled
|
||||
endpoint. The counts are never capped, so the panel that shows this list says
|
||||
`… and N more files` when it is showing less than all of it. Each entry
|
||||
carries its two-character `git status --porcelain` code (`status`, for a
|
||||
rename the `path` is the NEW one) and its line delta.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD`, which covers staged
|
||||
and unstaged work together. They are `null` when git has no delta to report:
|
||||
an **untracked** file (git has never seen it — its content is deliberately not
|
||||
read off disk, since a lane mid-build can have a 100 MB artifact), a **binary**
|
||||
file, a **rename** (numstat keys those differently), or a repo with no commit
|
||||
to diff against yet.
|
||||
- Hovering (or focusing, or tapping) the `N modified · N untracked` text on a
|
||||
lane card opens this list as a panel: one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
three times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
five times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
`lane_update`. Putting it there would fire a burst of subprocesses behind every
|
||||
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||
every 30 seconds, and a failed fetch is silent.
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.6.6
|
||||
version: 1.6.8
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -10690,7 +10690,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: A lane's working-copy facts
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
operationId: getLaneGitFacts
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -10700,7 +10700,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.8",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
||||
});
|
||||
|
||||
// The Workspace's inline rename is a text field PATCHing this route, so a
|
||||
// title is the one lane column a user types directly. Bad input on any
|
||||
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
|
||||
// raises a datatype error Express can only report as a 500.
|
||||
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
|
||||
const id = created.body.lane.id;
|
||||
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
|
||||
assert.equal(named.status, 200);
|
||||
assert.equal(named.body.lane.title, "checkout flow");
|
||||
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
|
||||
assert.equal(cleared.body.lane.title, "");
|
||||
|
||||
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
|
||||
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
|
||||
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
}
|
||||
// A NOT NULL column patched to null is the caller's mistake too.
|
||||
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
|
||||
assert.equal(notNull.status, 400);
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("rejects cross-origin lane deletion", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
||||
const id = created.body.lane.id;
|
||||
|
||||
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
|
||||
assert.equal(after.tool, "codex");
|
||||
assert.equal(after.title, "x");
|
||||
});
|
||||
|
||||
// The Workspace's inline rename PATCHes `title` straight from a text field,
|
||||
// so the column is now reachable by anything same-origin. A non-string would
|
||||
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
|
||||
// plain bad input.
|
||||
it("normalizes a title on create and on update", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
|
||||
assert.equal(lane.title, "spaced");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
|
||||
// null clears rather than violating the NOT NULL, and the empty string is
|
||||
// a real value: it puts the lane's cwd back in the UI's place for it.
|
||||
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
|
||||
});
|
||||
|
||||
// Every free-text column, not just the one the rename field exposes: a
|
||||
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
|
||||
// only report as a 500 on what is plainly the caller's mistake.
|
||||
it("rejects a non-scalar patch value on any patchable column", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
|
||||
for (const patch of [
|
||||
{ title: { evil: true } },
|
||||
{ notes: { a: 1 } },
|
||||
{ branch: ["main"] },
|
||||
{ ci_status: true },
|
||||
{ session_id: () => "x" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => lanes.updateLane(lane.id, patch),
|
||||
(err) => err.code === "EBADFIELD",
|
||||
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
|
||||
(err) => err.code === "EBADFIELD"
|
||||
);
|
||||
// `links` is the one patchable column that IS an object — it is stored as
|
||||
// JSON, so it must keep working.
|
||||
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
|
||||
});
|
||||
});
|
||||
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
|
||||
@@ -100,6 +100,16 @@ describe("worktree", () => {
|
||||
assert.equal(s.dirty, 1);
|
||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||
// The files the counts came from, each with its porcelain status code and
|
||||
// its line delta. An UNTRACKED file has no delta to report — git has never
|
||||
// seen it — so its counts are null rather than invented.
|
||||
assert.deepEqual(
|
||||
s.files.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[
|
||||
{ path: "README.md", status: " M", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
]
|
||||
);
|
||||
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||
|
||||
+50
-2
@@ -138,6 +138,28 @@ function hydrate(row) {
|
||||
return { ...row, stages, links, ports };
|
||||
}
|
||||
|
||||
/**
|
||||
* A lane's display name, as a column value. The title column is NOT NULL TEXT
|
||||
* and the dashboard's rename field writes it straight through, so it is
|
||||
* trimmed and capped — a pasted file is not a lane name. `null` clears the
|
||||
* title rather than violating the NOT NULL, because clearing is legitimate:
|
||||
* every render is `lane.title || lane.cwd`. A non-scalar is bad input and is
|
||||
* rejected the same way every other patch value is (see `bindable`).
|
||||
*
|
||||
* @param {unknown} value - Whatever the caller offered as a title.
|
||||
* @returns {string} A safe column value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
const TITLE_MAX = 200;
|
||||
function normalizeTitle(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
const t = typeof value;
|
||||
if (t !== "string" && t !== "number" && t !== "bigint") {
|
||||
throw Object.assign(new Error("title must be a string"), { code: "EBADFIELD" });
|
||||
}
|
||||
return String(value).trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
function createLane({
|
||||
title = "",
|
||||
cwd,
|
||||
@@ -161,7 +183,7 @@ function createLane({
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
normalizeTitle(title),
|
||||
cwd.replace(/\/+$/, ""),
|
||||
branch,
|
||||
pipeline,
|
||||
@@ -184,6 +206,31 @@ function getLane(id) {
|
||||
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch value, as something better-sqlite3 will actually bind.
|
||||
*
|
||||
* It binds numbers, strings, bigints, buffers and null — and THROWS on
|
||||
* anything else, including the `true` and the `{}` a hand-rolled PATCH can
|
||||
* send for any free-text lane column. That throw is indistinguishable from a
|
||||
* server fault by the time it reaches Express, so the route answered 500 to
|
||||
* what is plainly bad input. Rejecting here gives every PATCHABLE field the
|
||||
* one rule, rather than a special case per field the UI happens to expose.
|
||||
*
|
||||
* @param {string} key - The column being patched, for the error message.
|
||||
* @param {unknown} value - Whatever the caller sent for it.
|
||||
* @returns {string|number|bigint|null} A bindable value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
function bindable(key, value) {
|
||||
if (key === "title") return normalizeTitle(value);
|
||||
if (value === null || value === undefined) return null;
|
||||
const t = typeof value;
|
||||
if (t === "string" || t === "number" || t === "bigint") return value;
|
||||
throw Object.assign(new Error(`${key} must be a string, a number or null`), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
}
|
||||
|
||||
function updateLane(id, patch = {}) {
|
||||
// Validate kind before building the UPDATE if it's being set
|
||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
||||
@@ -197,7 +244,8 @@ function updateLane(id, patch = {}) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!PATCHABLE.has(k)) continue;
|
||||
cols.push(`${k} = ?`);
|
||||
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
|
||||
if (k === "links" && v !== null && typeof v === "object") vals.push(JSON.stringify(v));
|
||||
else vals.push(bindable(k, v));
|
||||
}
|
||||
if (cols.length) {
|
||||
cols.push("updated_at = ?");
|
||||
|
||||
+60
-4
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
||||
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||
const STATUS_FILE_CAP = 20;
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
@@ -546,14 +548,54 @@ async function removeWorktree(lane) {
|
||||
await deleteBranchSafely(sourceRepo, branch, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file line deltas against HEAD, keyed by path.
|
||||
*
|
||||
* One `git diff --numstat HEAD` covers staged AND unstaged work in a single
|
||||
* subprocess, which is the whole reason it is done this way: the alternative
|
||||
* is one process per file. It says nothing about UNTRACKED files — git has
|
||||
* never seen them, so they have no delta to report and their counts stay null
|
||||
* rather than being invented by reading the file off disk (a lane mid-build
|
||||
* can have an untracked 100 MB artifact).
|
||||
*
|
||||
* A repo with no commits yet has no HEAD to diff against; that throw is not a
|
||||
* fault, it just means every count is unknown.
|
||||
*/
|
||||
async function numstatByPath(dir) {
|
||||
const stats = new Map();
|
||||
try {
|
||||
const result = await git(dir, ["diff", "--numstat", "HEAD"]);
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
// A binary file reports "-" for both, not a number.
|
||||
if (m) {
|
||||
stats.set(m[3], {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no HEAD to diff against -> no per-file deltas */
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||
* Lines starting with ?? are untracked; others are dirty.
|
||||
*
|
||||
* `files` names WHICH files the counts mean, each with its porcelain status
|
||||
* code and its line delta, capped because this rides a polled endpoint and a
|
||||
* lane mid-`npm install` can report thousands. The COUNTS stay exact — only
|
||||
* the listing is capped.
|
||||
*/
|
||||
async function statusCounts(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const stats = await numstatByPath(dir);
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
const files = [];
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -562,29 +604,43 @@ async function statusCounts(dir) {
|
||||
} else {
|
||||
dirty++;
|
||||
}
|
||||
if (files.length < STATUS_FILE_CAP) {
|
||||
// "XY path", and for a rename "XY old -> new" — the new path is the one
|
||||
// that exists now and the one numstat is keyed by. git quotes a path
|
||||
// containing anything unusual; the quotes are presentation, not name.
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const path = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
files.push({
|
||||
path,
|
||||
status: line.slice(0, 2),
|
||||
added: null,
|
||||
deleted: null,
|
||||
...stats.get(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get short commit hash
|
||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||
const head = headResult.stdout.trim();
|
||||
|
||||
return { dirty, untracked, head };
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
*
|
||||
* Read-only and cheap, but it is three subprocesses, which is why it lives
|
||||
* Read-only and cheap, but it is five subprocesses, which is why it lives
|
||||
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
|
||||
* caller shows what git says rather than inventing a nicer word for it.
|
||||
*/
|
||||
async function gitFacts(dir) {
|
||||
const { dirty, untracked, head } = await statusCounts(dir);
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked };
|
||||
return { branch, head, subject, dirty, untracked, files };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,13 +268,13 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's working-copy facts",
|
||||
description:
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
operationId: "getLaneGitFacts",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
||||
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
|
||||
+17
-6
@@ -412,7 +412,12 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
|
||||
if (
|
||||
err.code === "EBADCWD" ||
|
||||
err.code === "EBADPIPELINE" ||
|
||||
err.code === "EBADKIND" ||
|
||||
err.code === "EBADFIELD"
|
||||
) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
@@ -432,12 +437,18 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
||||
// fault — every sibling route answers 400 here, so this one must too
|
||||
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
|
||||
// here for the same reason: a NOT NULL column patched to null is the
|
||||
// caller's mistake, and enumerating which columns those are in JS would
|
||||
// just be the schema written twice.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
@@ -690,7 +701,7 @@ router.get("/:id/integrations/:name", (req, res) => {
|
||||
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
||||
* exists for the destructive actions.
|
||||
*
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git five
|
||||
* times, and that payload is polled and re-broadcast on every hook-driven
|
||||
* lane_update. Any failure — no such directory, not a repo, git itself
|
||||
* erroring — is reported as `available: false` rather than a 500, because a
|
||||
|
||||
Reference in New Issue
Block a user