Compare commits
31 Commits
7e2bb6225f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e30fefab49 | |||
| 8aaa200dd9 | |||
| 8e49d2b300 | |||
| f053051a5d | |||
| 1fa52d1bfc | |||
| ab6d6410d5 | |||
| 37adf983e3 | |||
| c25008ab19 | |||
| 174c650624 | |||
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 | |||
| bab19e2f36 | |||
| 6f22aed47c | |||
| d542fbbf4b | |||
| 764dc6a7b5 | |||
| 06817b7901 | |||
| 14f116bf00 | |||
| 6dda604362 | |||
| 22ce61bcfe | |||
| 8a61a2b359 | |||
| b1d43bf098 | |||
| 11b779479d | |||
| 18a1ecb6f9 | |||
| fa416b5e6b | |||
| 0f15800b23 | |||
| 43f29ee904 | |||
| 18a42873b2 | |||
| b0bfc66d65 | |||
| 78fb82b257 |
@@ -8,7 +8,7 @@
|
|||||||
## Repo map
|
## Repo map
|
||||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
||||||
- `client/`: React + Vite UI.
|
- `client/`: React + Vite UI.
|
||||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `ccam update-check`.)
|
||||||
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
||||||
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
* @file UpdateNotifier.tsx
|
* @file UpdateNotifier.tsx
|
||||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||||
* command to update, and copy-to-clipboard — the dashboard never pulls or
|
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||||
* restarts itself.
|
* fast-forwardable branch — an "Update now" button that calls
|
||||||
|
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||||
|
* then polls until it's back and reloads the page.
|
||||||
*
|
*
|
||||||
* ## State sources
|
* ## State sources
|
||||||
* - Initial fetch via `api.updates.status()` on mount.
|
* - Initial fetch via `api.updates.status()` on mount.
|
||||||
|
* - Background re-check via `api.updates.check()` every hour
|
||||||
|
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
||||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||||
*
|
*
|
||||||
* ## Dismissal persistence
|
* ## Dismissal persistence
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Download, X, Copy, Check, RefreshCw } from "lucide-react";
|
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
@@ -71,6 +75,15 @@ import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
|||||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||||
|
|
||||||
|
/** How often to silently re-check for updates in the background. */
|
||||||
|
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
||||||
|
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
||||||
|
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
||||||
|
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
||||||
|
}
|
||||||
|
|
||||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||||
@@ -96,6 +109,8 @@ export function UpdateNotifier() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
@@ -128,6 +143,19 @@ export function UpdateNotifier() {
|
|||||||
});
|
});
|
||||||
}, [syncFromPayload]);
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
|
// Background re-check every hour, on top of the initial mount fetch and the
|
||||||
|
// manual "Check now" button — so a long-lived tab notices an update without
|
||||||
|
// the user having to click anything.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
api.updates
|
||||||
|
.check()
|
||||||
|
.then(syncFromPayload)
|
||||||
|
.catch(() => {});
|
||||||
|
}, AUTO_CHECK_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setDismissedSha(null);
|
const handler = () => setDismissedSha(null);
|
||||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||||
@@ -139,14 +167,14 @@ export function UpdateNotifier() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (!status?.remote_sha) return;
|
if (restarting || !status?.remote_sha) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setDismissedSha(status.remote_sha);
|
setDismissedSha(status.remote_sha);
|
||||||
}, [status?.remote_sha]);
|
}, [restarting, status?.remote_sha]);
|
||||||
|
|
||||||
// Escape to dismiss - standard modal affordance.
|
// Escape to dismiss - standard modal affordance.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,6 +211,41 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Polls `status()` until the restarted server answers again, then reloads
|
||||||
|
// so the tab picks up the new client bundle too — the server can't push
|
||||||
|
// this over its own WebSocket since it's mid-restart.
|
||||||
|
const pollUntilBack = useCallback(() => {
|
||||||
|
const attempt = () => {
|
||||||
|
api.updates
|
||||||
|
.status()
|
||||||
|
.then(() => window.location.reload())
|
||||||
|
.catch(() => setTimeout(attempt, 1500));
|
||||||
|
};
|
||||||
|
setTimeout(attempt, 1500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyNow = async () => {
|
||||||
|
if (applying || restarting) return;
|
||||||
|
setError(null);
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const result = await api.updates.apply();
|
||||||
|
if (result.applied) {
|
||||||
|
setApplying(false);
|
||||||
|
setRestarting(true);
|
||||||
|
pollUntilBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t("applyError"));
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -266,6 +329,13 @@ export function UpdateNotifier() {
|
|||||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{restarting ? (
|
||||||
|
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||||
|
{t("restarting")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-status-danger" role="alert">
|
<p className="text-xs text-status-danger" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -278,13 +348,18 @@ export function UpdateNotifier() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={checkNow}
|
onClick={checkNow}
|
||||||
disabled={checking}
|
disabled={checking || applying || restarting}
|
||||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||||
{checking ? t("checking") : t("checkNow")}
|
{checking ? t("checking") : t("checkNow")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={dismiss} className="btn-ghost">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
disabled={restarting}
|
||||||
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
{t("dismiss")}
|
{t("dismiss")}
|
||||||
</button>
|
</button>
|
||||||
{status.manual_command ? (
|
{status.manual_command ? (
|
||||||
@@ -292,12 +367,27 @@ export function UpdateNotifier() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={copyCmd}
|
onClick={copyCmd}
|
||||||
disabled={copied}
|
disabled={copied}
|
||||||
className="btn-primary disabled:opacity-70"
|
className="btn-ghost disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
{copied ? t("copied") : t("copy")}
|
{copied ? t("copied") : t("copy")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isAutoApplicable(status.situation) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyNow}
|
||||||
|
disabled={applying || restarting}
|
||||||
|
className="btn-primary disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{applying || restarting ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-4 h-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @file UpdateNotifier.test.tsx
|
||||||
|
* @description Pins the "Update now" self-apply flow added on top of the
|
||||||
|
* existing manual-command modal: the button only renders when the checkout
|
||||||
|
* situation is fast-forwardable, clicking it calls `api.updates.apply()`,
|
||||||
|
* and a successful `applied: true` response flips the modal into a
|
||||||
|
* "restarting" state that polls `api.updates.status()` until it resolves.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { UpdateNotifier } from "../UpdateNotifier";
|
||||||
|
import type { UpdateStatusPayload } from "../../lib/types";
|
||||||
|
|
||||||
|
const statusMock = vi.fn();
|
||||||
|
const checkMock = vi.fn();
|
||||||
|
const applyMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
updates: {
|
||||||
|
status: (...args: unknown[]) => statusMock(...args),
|
||||||
|
check: (...args: unknown[]) => checkMock(...args),
|
||||||
|
apply: (...args: unknown[]) => applyMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const BASE_STATUS: UpdateStatusPayload = {
|
||||||
|
git_repo: true,
|
||||||
|
update_available: true,
|
||||||
|
repo_root: "/repo",
|
||||||
|
remote_ref: "origin/main",
|
||||||
|
canonical_remote: "origin",
|
||||||
|
current_branch: "main",
|
||||||
|
tracking_upstream: "origin/main",
|
||||||
|
tracks_canonical: true,
|
||||||
|
situation: "tracking_canonical",
|
||||||
|
situation_note: null,
|
||||||
|
local_sha: "aaa",
|
||||||
|
remote_sha: "bbb",
|
||||||
|
commits_behind: 2,
|
||||||
|
manual_command: 'cd "/repo" && git pull --ff-only && npm run setup',
|
||||||
|
message: "2 commit(s) on origin/main not in your checkout.",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
|
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
|
applyMock.mockReset();
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch {
|
||||||
|
// Some CI environments stub a non-functional localStorage; the component
|
||||||
|
// already tolerates that (see UpdateNotifier's own try/catch), so tests do too.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — Update now", () => {
|
||||||
|
it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => {
|
||||||
|
applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||||
|
await userEvent.click(updateBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(applyMock).toHaveBeenCalled());
|
||||||
|
// Appears both in the status banner and the button label while restarting.
|
||||||
|
const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting"));
|
||||||
|
expect(restartingNodes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Update now button for a non-fast-forwardable branch", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "feature_branch",
|
||||||
|
tracks_canonical: false,
|
||||||
|
});
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||||
|
applyMock.mockResolvedValue({
|
||||||
|
...BASE_STATUS,
|
||||||
|
applied: false,
|
||||||
|
reason: "not_fast_forwardable",
|
||||||
|
});
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
|
||||||
|
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||||
|
await userEvent.click(updateBtn);
|
||||||
|
|
||||||
|
expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||||
* because the full card, its controls and its working-copy facts live in the
|
* stage, how far — because the full card, its controls and its working-copy
|
||||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||||
* scannable in one horizontal row.
|
* width is what lets many lanes stay scannable in one scrolling column.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export default function LaneStripCard({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
title={lane.cwd}
|
title={lane.cwd}
|
||||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* @file LaneConsolePane.tsx
|
||||||
|
* @description One lane's run console: the RunSetup ↔ TerminalView switcher,
|
||||||
|
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
|
||||||
|
* these side by side (split terminal view). Owns its own prompt/cwd/model/
|
||||||
|
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
|
||||||
|
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
|
||||||
|
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
|
||||||
|
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||||
|
* requests for an N-pane layout.
|
||||||
|
*
|
||||||
|
* That state is bound to the lane the pane currently shows: switching `laneId`
|
||||||
|
* swaps the whole pane over to the new lane — its cwd, its history, and its
|
||||||
|
* live tmux session — instead of leaving the previous lane's terminal on
|
||||||
|
* screen under a new lane's header.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Play, AlertCircle } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
CwdSuggestion,
|
||||||
|
DashboardRunHistoryItem,
|
||||||
|
EffortLevel,
|
||||||
|
PermissionMode,
|
||||||
|
RunHandle,
|
||||||
|
RunListResponse,
|
||||||
|
RunStartArgs,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type { Session, Lane } from "../../lib/types";
|
||||||
|
import { TerminalView } from "./TerminalView";
|
||||||
|
import { RunSetup } from "./RunSetup";
|
||||||
|
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||||
|
|
||||||
|
export interface LaneConsolePaneProps {
|
||||||
|
lanes: Lane[];
|
||||||
|
laneId: number | null;
|
||||||
|
showLaneSelector: boolean;
|
||||||
|
onLaneIdChange: (id: number) => void;
|
||||||
|
onLaneCreated: (lane: Lane) => void;
|
||||||
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
|
cwdSuggestions: CwdSuggestion[];
|
||||||
|
activeRuns: RunListResponse | null;
|
||||||
|
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||||
|
* active-runs switcher alongside dashboard runs. */
|
||||||
|
externalSessions?: Session[];
|
||||||
|
wsConnected: boolean;
|
||||||
|
defaultCwd?: string;
|
||||||
|
onHasActiveRunChange?: (active: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LaneConsolePane({
|
||||||
|
lanes,
|
||||||
|
laneId,
|
||||||
|
showLaneSelector,
|
||||||
|
onLaneIdChange,
|
||||||
|
onLaneCreated,
|
||||||
|
binaryStatus,
|
||||||
|
cwdSuggestions,
|
||||||
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
|
wsConnected,
|
||||||
|
defaultCwd,
|
||||||
|
onHasActiveRunChange,
|
||||||
|
}: LaneConsolePaneProps) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
const { t: tCommon } = useTranslation("common");
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||||
|
const [effort, setEffort] = useState<EffortLevel>("");
|
||||||
|
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||||
|
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||||
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||||
|
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
||||||
|
|
||||||
|
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onHasActiveRunChange?.(handle !== null);
|
||||||
|
}, [handle, onHasActiveRunChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLane && defaultCwd && cwd === "") {
|
||||||
|
setCwd(defaultCwd);
|
||||||
|
}
|
||||||
|
}, [defaultCwd, currentLane, cwd]);
|
||||||
|
|
||||||
|
const refreshList = useCallback(() => {
|
||||||
|
if (laneId !== null) {
|
||||||
|
api.run
|
||||||
|
.history(50, { laneId })
|
||||||
|
.then((r) => setRunHistory(r.items))
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
api.run
|
||||||
|
.history(50)
|
||||||
|
.then((r) => setRunHistory(r.items))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, [laneId]);
|
||||||
|
|
||||||
|
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
|
||||||
|
// reading them through a ref keeps the lane-switch effect below off their
|
||||||
|
// identity — a background poll must not wipe a half-typed prompt.
|
||||||
|
const latest = useRef({ lanes, activeRuns, defaultCwd });
|
||||||
|
latest.current = { lanes, activeRuns, defaultCwd };
|
||||||
|
|
||||||
|
// A pane's prompt, cwd, history and terminal all belong to the lane it
|
||||||
|
// shows, so switching lanes has to swap every one of them. Re-attach right
|
||||||
|
// away when the new lane already has a live run: each lane sticks to its own
|
||||||
|
// tmux session, and the switch should land on that session rather than on an
|
||||||
|
// empty setup form the user then has to Start out of.
|
||||||
|
const autoAttachedForLane = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const { activeRuns: runs } = latest.current;
|
||||||
|
setPrompt("");
|
||||||
|
setResumeSession(null);
|
||||||
|
setError(null);
|
||||||
|
setBusy(null);
|
||||||
|
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||||
|
autoAttachedForLane.current = null;
|
||||||
|
refreshList();
|
||||||
|
}, [laneId, refreshList]);
|
||||||
|
|
||||||
|
// The switch effect above only sees whatever `activeRuns` the page already
|
||||||
|
// had loaded at that instant. Remounting this page (navigating away and
|
||||||
|
// back) starts `activeRuns` at null again, so a lane with a live run would
|
||||||
|
// otherwise show the setup form until the user switched lanes and back —
|
||||||
|
// the only path that re-ran the effect after the poll caught up. Re-check
|
||||||
|
// once `activeRuns` actually arrives, but only once per lane so it never
|
||||||
|
// fights a user-initiated "New Run".
|
||||||
|
useEffect(() => {
|
||||||
|
if (handle || laneId === null) return;
|
||||||
|
if (autoAttachedForLane.current === laneId) return;
|
||||||
|
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
||||||
|
if (running) {
|
||||||
|
autoAttachedForLane.current = laneId;
|
||||||
|
setHandle(running);
|
||||||
|
}
|
||||||
|
}, [activeRuns, laneId, handle]);
|
||||||
|
|
||||||
|
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||||
|
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||||
|
// list has loaded (split view restores its pane lanes from localStorage),
|
||||||
|
// and `laneId` never changes afterwards, so a laneId-only effect would leave
|
||||||
|
// the console pointing at the default directory. RunSetup submits this
|
||||||
|
// string verbatim, so a stale one starts the run in the wrong folder.
|
||||||
|
const laneCwd = currentLane?.cwd ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (laneCwd) setCwd(laneCwd);
|
||||||
|
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
|
||||||
|
}, [laneId, laneCwd]);
|
||||||
|
|
||||||
|
const attachToRun = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("attach");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fetched = await api.run.get(id);
|
||||||
|
setHandle(fetched);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const m = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.attachFailed", { message: m }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStartFromSetup = useCallback(
|
||||||
|
async (args: RunStartArgs) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("start");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const effectiveCwd = args.cwd || undefined;
|
||||||
|
|
||||||
|
if (!effectiveCwd) {
|
||||||
|
throw new Error(t("errors.cwdRequired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the lane from the cwd the user actually typed, not from
|
||||||
|
// args.laneId — RunSetup always supplies this pane's laneId (a
|
||||||
|
// required prop), which would otherwise silently start a run in the
|
||||||
|
// wrong lane whenever the user types a cwd different from the one
|
||||||
|
// this pane currently shows.
|
||||||
|
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
||||||
|
let targetLaneId: number;
|
||||||
|
if (ownedLane) {
|
||||||
|
targetLaneId = ownedLane.id;
|
||||||
|
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||||
|
targetLaneId = ensureResult.lane.id;
|
||||||
|
onLaneIdChange(ensureResult.lane.id);
|
||||||
|
onLaneCreated(ensureResult.lane);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
t("errors.laneCreateFailed", {
|
||||||
|
message: err instanceof Error ? err.message : "unknown",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let laneStartResult;
|
||||||
|
try {
|
||||||
|
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||||
|
prompt: args.initialPrompt || "",
|
||||||
|
model: args.model || undefined,
|
||||||
|
permissionMode: args.permissionMode,
|
||||||
|
resumeSessionId: args.resumeSessionId,
|
||||||
|
effort: args.effort || undefined,
|
||||||
|
});
|
||||||
|
} catch (laneErr: unknown) {
|
||||||
|
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||||
|
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||||
|
const fresh = await api.lanes.list().catch(() => null);
|
||||||
|
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||||
|
if (updatedLane?.run_id) {
|
||||||
|
await attachToRun(updatedLane.run_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw laneErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!laneStartResult.lane?.run_id) {
|
||||||
|
throw new Error(t("errors.noRunIdReturned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||||
|
setHandle(fetched);
|
||||||
|
refreshList();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await attachToRun(laneStartResult.lane.run_id);
|
||||||
|
refreshList();
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||||
|
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const m = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.startFailed", { message: m }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onResumeFromHistory = useCallback(
|
||||||
|
async (item: DashboardRunHistoryItem) => {
|
||||||
|
if (!item.session_id) return;
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("start");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let fetched: RunHandle;
|
||||||
|
|
||||||
|
if (item.cwd) {
|
||||||
|
const effectiveCwd = item.cwd;
|
||||||
|
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
|
||||||
|
|
||||||
|
if (!targetLaneId) {
|
||||||
|
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||||
|
targetLaneId = ensureResult.lane.id;
|
||||||
|
onLaneCreated(ensureResult.lane);
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||||
|
prompt: "",
|
||||||
|
model: item.model || undefined,
|
||||||
|
permissionMode: item.permission_mode || undefined,
|
||||||
|
effort: item.effort || undefined,
|
||||||
|
resumeSessionId: item.session_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!laneStartResult.lane?.run_id) {
|
||||||
|
throw new Error("No run_id returned from lane start");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||||
|
onLaneIdChange(targetLaneId);
|
||||||
|
} else {
|
||||||
|
fetched = await api.run.start({
|
||||||
|
laneId: 0,
|
||||||
|
initialPrompt: "",
|
||||||
|
cwd: undefined,
|
||||||
|
model: item.model || undefined,
|
||||||
|
permissionMode: item.permission_mode || undefined,
|
||||||
|
effort: item.effort || undefined,
|
||||||
|
resumeSessionId: item.session_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHandle(fetched);
|
||||||
|
setResumeSession(null);
|
||||||
|
refreshList();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "unknown";
|
||||||
|
setError(t("errors.startFailed", { message: msg }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onViewFromHistory = useCallback(
|
||||||
|
(item: DashboardRunHistoryItem) => {
|
||||||
|
if (item.session_id) void onResumeFromHistory(item);
|
||||||
|
},
|
||||||
|
[onResumeFromHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRun = useCallback(() => {
|
||||||
|
setHandle(null);
|
||||||
|
setPrompt("");
|
||||||
|
setResumeSession(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (laneId === null && showLaneSelector) {
|
||||||
|
return (
|
||||||
|
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||||
|
<select
|
||||||
|
data-testid="pane-lane-select"
|
||||||
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||||
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||||
|
{lanes.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.title || l.cwd}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
||||||
|
{showLaneSelector && (
|
||||||
|
<select
|
||||||
|
data-testid="pane-lane-select"
|
||||||
|
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||||
|
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||||
|
value={laneId ?? ""}
|
||||||
|
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||||
|
{lanes.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.title || l.cwd}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Play className="w-4.5 h-4.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||||
|
{wsConnected ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||||
|
{tCommon("live")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
||||||
|
{tCommon("offline")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<ActiveRunsSwitcher
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
currentHandleId={handle?.id || null}
|
||||||
|
onAttach={attachToRun}
|
||||||
|
runHistory={runHistory}
|
||||||
|
externalSessions={externalSessions}
|
||||||
|
onResumeFromHistory={onResumeFromHistory}
|
||||||
|
onViewFromHistory={onViewFromHistory}
|
||||||
|
onRefresh={refreshList}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{binaryStatus && !binaryStatus.found && (
|
||||||
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{t("binary.missing")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 break-all">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!handle ? (
|
||||||
|
<RunSetup
|
||||||
|
laneId={laneId ?? 0}
|
||||||
|
prompt={prompt}
|
||||||
|
onPromptChange={setPrompt}
|
||||||
|
cwd={cwd}
|
||||||
|
onCwdChange={setCwd}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
model={model}
|
||||||
|
onModelChange={setModel}
|
||||||
|
permissionMode={permissionMode}
|
||||||
|
onPermissionModeChange={setPermissionMode}
|
||||||
|
effort={effort}
|
||||||
|
onEffortChange={setEffort}
|
||||||
|
binaryFound={binaryStatus?.found ?? true}
|
||||||
|
busy={busy === "start"}
|
||||||
|
onStart={onStartFromSetup}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
laneCwd={currentLane?.cwd}
|
||||||
|
resumeSession={resumeSession}
|
||||||
|
onResumeSessionChange={setResumeSession}
|
||||||
|
runHistory={runHistory}
|
||||||
|
onResumeFromHistory={onResumeFromHistory}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<TerminalView
|
||||||
|
runId={handle!.id}
|
||||||
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={newRun}
|
||||||
|
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("actions.newRun")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,16 @@
|
|||||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||||
* Resume / View actions.
|
* Resume / View actions.
|
||||||
*
|
*
|
||||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
* "Active runs" counts everything actually running. They carry no tmux session
|
||||||
|
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||||
|
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||||
|
*
|
||||||
|
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||||
|
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||||
|
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||||
|
* just calls `onRefresh`.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +41,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||||
|
import type { Session } from "../../lib/types";
|
||||||
|
|
||||||
// Minimal StatusPill component (from deleted RunConsole)
|
// Minimal StatusPill component (from deleted RunConsole)
|
||||||
function StatusPill({
|
function StatusPill({
|
||||||
@@ -74,6 +82,10 @@ export interface UnifiedRunRow {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
|
/** Live Claude Code session this dashboard did not spawn — no tmux session to
|
||||||
|
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
|
||||||
|
* action. */
|
||||||
|
external?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveRunsSwitcher({
|
export function ActiveRunsSwitcher({
|
||||||
@@ -81,6 +93,7 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId,
|
currentHandleId,
|
||||||
onAttach,
|
onAttach,
|
||||||
runHistory,
|
runHistory,
|
||||||
|
externalSessions = [],
|
||||||
onResumeFromHistory,
|
onResumeFromHistory,
|
||||||
onViewFromHistory,
|
onViewFromHistory,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -89,6 +102,9 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId: string | null;
|
currentHandleId: string | null;
|
||||||
onAttach: (id: string) => void;
|
onAttach: (id: string) => void;
|
||||||
runHistory: DashboardRunHistoryItem[];
|
runHistory: DashboardRunHistoryItem[];
|
||||||
|
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
|
||||||
|
* cwd-less sessions are ignored — neither can be resumed on this machine. */
|
||||||
|
externalSessions?: Session[];
|
||||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -111,14 +127,18 @@ export function ActiveRunsSwitcher({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Merge live in-memory handles + persistent history into one row list.
|
// Merge live in-memory handles + persistent history + externally started
|
||||||
// Live entries dedupe past-history entries with the same id.
|
// sessions into one row list. Live entries dedupe past-history entries with
|
||||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
// the same id; a session id already covered by a run row is never repeated as
|
||||||
|
// an external row.
|
||||||
|
const { rows, historyItems } = useMemo(() => {
|
||||||
const out: UnifiedRunRow[] = [];
|
const out: UnifiedRunRow[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const seenSessions = new Set<string>();
|
||||||
if (activeRuns) {
|
if (activeRuns) {
|
||||||
for (const r of activeRuns.items) {
|
for (const r of activeRuns.items) {
|
||||||
seen.add(r.id);
|
seen.add(r.id);
|
||||||
|
if (r.sessionId) seenSessions.add(r.sessionId);
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
sessionId: r.sessionId,
|
sessionId: r.sessionId,
|
||||||
@@ -135,6 +155,7 @@ export function ActiveRunsSwitcher({
|
|||||||
for (const h of runHistory) {
|
for (const h of runHistory) {
|
||||||
if (seen.has(h.id)) continue;
|
if (seen.has(h.id)) continue;
|
||||||
seen.add(h.id);
|
seen.add(h.id);
|
||||||
|
if (h.session_id) seenSessions.add(h.session_id);
|
||||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||||
out.push({
|
out.push({
|
||||||
@@ -149,9 +170,49 @@ export function ActiveRunsSwitcher({
|
|||||||
isLive: h.isLive,
|
isLive: h.isLive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Externally started sessions: shown as live rows, and mirrored as
|
||||||
|
// synthetic history items so the existing resume path (which only reads
|
||||||
|
// session_id / cwd / model) works on them unchanged.
|
||||||
|
const synthetic: DashboardRunHistoryItem[] = [];
|
||||||
|
for (const s of externalSessions) {
|
||||||
|
if (!s.cwd) continue;
|
||||||
|
if (s.source && s.source !== "local") continue;
|
||||||
|
if (seenSessions.has(s.id)) continue;
|
||||||
|
seenSessions.add(s.id);
|
||||||
|
synthetic.push({
|
||||||
|
id: `session:${s.id}`,
|
||||||
|
session_id: s.id,
|
||||||
|
cwd: s.cwd,
|
||||||
|
model: s.model,
|
||||||
|
permission_mode: null,
|
||||||
|
effort: null,
|
||||||
|
resume_session_id: null,
|
||||||
|
prompt_preview: s.name,
|
||||||
|
status: "running",
|
||||||
|
exit_code: null,
|
||||||
|
started_at: s.started_at,
|
||||||
|
ended_at: null,
|
||||||
|
isLive: true,
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
id: `session:${s.id}`,
|
||||||
|
sessionId: s.id,
|
||||||
|
cwd: s.cwd,
|
||||||
|
model: s.model,
|
||||||
|
status: "running",
|
||||||
|
promptPreview: s.name || "",
|
||||||
|
startedAt: new Date(s.started_at).getTime() || 0,
|
||||||
|
endedAt: null,
|
||||||
|
isLive: true,
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
out.sort((a, b) => b.startedAt - a.startedAt);
|
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||||
return out;
|
return {
|
||||||
}, [activeRuns, runHistory]);
|
rows: out,
|
||||||
|
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
|
||||||
|
};
|
||||||
|
}, [activeRuns, runHistory, externalSessions]);
|
||||||
|
|
||||||
const liveCount = rows.filter((r) => r.isLive).length;
|
const liveCount = rows.filter((r) => r.isLive).length;
|
||||||
const totalCount = rows.length;
|
const totalCount = rows.length;
|
||||||
@@ -196,7 +257,7 @@ export function ActiveRunsSwitcher({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
onViewFromHistory(item);
|
onViewFromHistory(item);
|
||||||
}}
|
}}
|
||||||
runHistory={runHistory}
|
runHistory={historyItems}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
@@ -467,8 +528,10 @@ function UnifiedRunRowView({
|
|||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
// Without mode distinction, offer resume for any finished run with a session
|
// Without mode distinction, offer resume for any finished run with a session.
|
||||||
const canResume = !!row.sessionId && !row.isLive;
|
// An external session is live but has no attachable tmux session, so Resume
|
||||||
|
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||||
|
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
|
||||||
const canView = !!row.sessionId && !row.isLive;
|
const canView = !!row.sessionId && !row.isLive;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -484,13 +547,21 @@ function UnifiedRunRowView({
|
|||||||
{t("runs.liveBadge", "live")}
|
{t("runs.liveBadge", "live")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{row.external && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||||
|
title={t("runs.externalHint")}
|
||||||
|
>
|
||||||
|
{t("runs.externalBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
||||||
{t("runs.currentBadge", "current")}
|
{t("runs.currentBadge", "current")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||||
{row.isLive && !isCurrent && (
|
{row.isLive && !row.external && !isCurrent && (
|
||||||
<button
|
<button
|
||||||
onClick={onAttach}
|
onClick={onAttach}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||||
@@ -502,6 +573,7 @@ function UnifiedRunRowView({
|
|||||||
{canResume && (
|
{canResume && (
|
||||||
<button
|
<button
|
||||||
onClick={onResume}
|
onClick={onResume}
|
||||||
|
title={row.external ? t("runs.externalHint") : undefined}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* model / permission-mode / effort fields, plus the concurrency hint and
|
* model / permission-mode / effort fields, plus the concurrency hint and
|
||||||
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
||||||
* so a missing `claude` binary is a surfaced state here rather than a probe
|
* so a missing `claude` binary is a surfaced state here rather than a probe
|
||||||
* of its own.
|
* of its own. Picking a session to resume starts that run immediately —
|
||||||
|
* a resume carries its own history, so there is nothing to type first;
|
||||||
|
* the prompt stays optional for resumes and required for fresh runs.
|
||||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||||
* `ModelPicker`, and the small `Field` layout helper.
|
* `ModelPicker`, and the small `Field` layout helper.
|
||||||
*
|
*
|
||||||
@@ -154,8 +156,16 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SessionPicker
|
<SessionPicker
|
||||||
selected={props.resumeSession}
|
selected={props.resumeSession}
|
||||||
onSelect={props.onResumeSessionChange}
|
onSelect={(s) => {
|
||||||
cwd={props.laneCwd}
|
props.onResumeSessionChange(s);
|
||||||
|
// Pass the picked session explicitly: the parent's state
|
||||||
|
// update has not landed yet on this tick, so reading
|
||||||
|
// props.resumeSession here would resume nothing.
|
||||||
|
if (s && !props.busy) handleStart(props, s);
|
||||||
|
}}
|
||||||
|
// Fall back to the typed cwd when no lane is selected — otherwise
|
||||||
|
// an unfiltered picker lists every session from every repo.
|
||||||
|
cwd={props.laneCwd || props.cwd.trim() || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -247,7 +257,8 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
onClick={() => handleStart(props)}
|
onClick={() => handleStart(props)}
|
||||||
disabled={
|
disabled={
|
||||||
!props.binaryFound ||
|
!props.binaryFound ||
|
||||||
!props.prompt.trim() ||
|
// A resume needs no prompt — the session it continues is the input.
|
||||||
|
(!props.resumeSession && !props.prompt.trim()) ||
|
||||||
props.busy ||
|
props.busy ||
|
||||||
atCap ||
|
atCap ||
|
||||||
(resumePicked && !props.resumeSession) ||
|
(resumePicked && !props.resumeSession) ||
|
||||||
@@ -270,14 +281,19 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStart(props: RunSetupProps) {
|
/** `session` overrides `props.resumeSession` for the auto-start fired straight
|
||||||
|
* out of the picker, before the parent's state has caught up. */
|
||||||
|
function handleStart(props: RunSetupProps, session?: Session) {
|
||||||
|
const resume = session ?? props.resumeSession;
|
||||||
props.onStart({
|
props.onStart({
|
||||||
laneId: props.laneId,
|
laneId: props.laneId,
|
||||||
cwd: props.cwd || undefined,
|
// A resume is pinned to its own session's folder — that's what the locked
|
||||||
|
// cwd field shows, so it's what gets sent.
|
||||||
|
cwd: resume?.cwd || props.cwd || undefined,
|
||||||
model: props.model || undefined,
|
model: props.model || undefined,
|
||||||
permissionMode: props.permissionMode || undefined,
|
permissionMode: props.permissionMode || undefined,
|
||||||
effort: props.effort || undefined,
|
effort: props.effort || undefined,
|
||||||
resumeSessionId: props.resumeSession?.id || undefined,
|
resumeSessionId: resume?.id || undefined,
|
||||||
initialPrompt: props.prompt || undefined,
|
initialPrompt: props.prompt || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,18 +29,23 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
|
|
||||||
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||||
|
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||||
|
// would hand onmessage a Blob that the string checks below never match,
|
||||||
|
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
};
|
};
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (typeof event.data === "string") {
|
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||||
// Binary PTY output arrives as text here too (the browser WS API
|
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||||
// decodes non-Blob/ArrayBuffer frames as strings) — a JSON control
|
if (typeof data === "string") {
|
||||||
// frame is the only thing that starts with `{"type"`.
|
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||||
if (event.data.startsWith('{"type"')) {
|
if (data.startsWith('{"type"')) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(data);
|
||||||
if (msg.type === "exit") {
|
if (msg.type === "exit") {
|
||||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||||
}
|
}
|
||||||
@@ -49,7 +54,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
|||||||
/* not JSON — fall through and render as PTY output */
|
/* not JSON — fall through and render as PTY output */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
term.write(event.data);
|
term.write(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* @file LaneConsolePane.test.tsx
|
||||||
|
* @description Test suite for the LaneConsolePane component
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { LaneConsolePane } from "../LaneConsolePane";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { Lane } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../TerminalView", () => ({
|
||||||
|
TerminalView: ({ runId }: { runId: string }) => (
|
||||||
|
<div data-testid="terminal-view" data-run-id={runId} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
lanes: {
|
||||||
|
ensure: vi.fn(),
|
||||||
|
action: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
|
run: {
|
||||||
|
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
get: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RUN_MODEL_CHOICES: [],
|
||||||
|
RUN_EFFORT_CHOICES: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LANE: Lane = {
|
||||||
|
id: 1,
|
||||||
|
title: "demo",
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
branch: null,
|
||||||
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
|
pipeline: "default",
|
||||||
|
session_id: null,
|
||||||
|
run_id: null,
|
||||||
|
stage: "idle",
|
||||||
|
stage_since: null,
|
||||||
|
status: "idle",
|
||||||
|
gate_decision: null,
|
||||||
|
ci_status: null,
|
||||||
|
needs_action: null,
|
||||||
|
links: {},
|
||||||
|
stages: {},
|
||||||
|
notes: null,
|
||||||
|
pipeline_name: "Default",
|
||||||
|
pipeline_nodes: [],
|
||||||
|
progress: 0,
|
||||||
|
stage_seconds: null,
|
||||||
|
last_event_seconds: null,
|
||||||
|
liveness: "idle" as Lane["liveness"],
|
||||||
|
detected_stage: null,
|
||||||
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
|
active_feature_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function baseProps() {
|
||||||
|
return {
|
||||||
|
lanes: [LANE],
|
||||||
|
laneId: 1,
|
||||||
|
showLaneSelector: false,
|
||||||
|
onLaneIdChange: vi.fn(),
|
||||||
|
onLaneCreated: vi.fn(),
|
||||||
|
binaryStatus: { found: true, path: "/usr/local/bin/claude" },
|
||||||
|
cwdSuggestions: [],
|
||||||
|
activeRuns: { items: [] },
|
||||||
|
wsConnected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LaneConsolePane", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => {
|
||||||
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
lane: { ...LANE, run_id: "run-1" },
|
||||||
|
});
|
||||||
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "run-1",
|
||||||
|
laneId: 1,
|
||||||
|
status: "running",
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: null,
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LaneConsolePane {...baseProps()} />);
|
||||||
|
|
||||||
|
// Set cwd and prompt
|
||||||
|
const cwdInput = screen.getByPlaceholderText(/type to search/i);
|
||||||
|
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
|
||||||
|
|
||||||
|
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
|
||||||
|
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
|
||||||
|
|
||||||
|
// Find and click the Run button (the main start button in RunSetup)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
|
||||||
|
);
|
||||||
|
expect(api.run.start).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||||
|
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||||
|
const run = (id: string, laneId: number) => ({
|
||||||
|
id,
|
||||||
|
laneId,
|
||||||
|
status: "running" as const,
|
||||||
|
cwd: null,
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: null,
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
});
|
||||||
|
const props = {
|
||||||
|
...baseProps(),
|
||||||
|
lanes: [LANE, LANE2],
|
||||||
|
activeRuns: { items: [run("ccam-lane-1", 1), run("ccam-lane-2", 2)] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1")
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...props} laneId={2} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-2")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A lane with no live run falls back to its setup form, not the previous
|
||||||
|
// lane's terminal.
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
|
||||||
|
// Split view restores its pane lanes from localStorage, so a pane can
|
||||||
|
// render with a laneId before GET /api/lanes has answered. laneId never
|
||||||
|
// changes afterwards — only the resolved lane does.
|
||||||
|
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
|
||||||
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||||
|
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
|
||||||
|
expect(cwdInput.value).toBe("/home/tester");
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
|
||||||
|
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a lane dropdown only when showLaneSelector is true", () => {
|
||||||
|
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
||||||
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
|
||||||
|
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
|
||||||
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
|
||||||
|
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||||
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||||
|
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||||
|
import type { Session } from "../../../lib/types";
|
||||||
|
|
||||||
const LIVE_ID = "run-live";
|
const LIVE_ID = "run-live";
|
||||||
const PAST_ID = "run-past";
|
const PAST_ID = "run-past";
|
||||||
@@ -67,6 +68,21 @@ const HEADLESS = historyItem({
|
|||||||
started_at: new Date(1000).toISOString(),
|
started_at: new Date(1000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function externalSession(over: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "sess-external",
|
||||||
|
name: "the external prompt",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/external",
|
||||||
|
model: "claude-opus-5",
|
||||||
|
started_at: new Date(3000).toISOString(),
|
||||||
|
ended_at: null,
|
||||||
|
updated_at: new Date(3000).toISOString(),
|
||||||
|
source: "local",
|
||||||
|
...over,
|
||||||
|
} as unknown as Session;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||||
const spies = {
|
const spies = {
|
||||||
onAttach: vi.fn(),
|
onAttach: vi.fn(),
|
||||||
@@ -188,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
|
|||||||
expect(screen.queryByText("stale copy")).toBeNull();
|
expect(screen.queryByText("stale copy")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
|
||||||
|
const { spies } = renderSwitcher({
|
||||||
|
activeRuns: null,
|
||||||
|
runHistory: [],
|
||||||
|
externalSessions: [externalSession()],
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||||
|
expect(screen.getByText("the external prompt")).toBeTruthy();
|
||||||
|
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
|
||||||
|
// No tmux session of ours to attach to — Resume is the only action.
|
||||||
|
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session:sess-external",
|
||||||
|
session_id: "sess-external",
|
||||||
|
cwd: "/tmp/external",
|
||||||
|
status: "running",
|
||||||
|
isLive: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
|
||||||
|
renderSwitcher({
|
||||||
|
runHistory: [],
|
||||||
|
externalSessions: [
|
||||||
|
externalSession({ id: "sess-live" }), // same session as the live run
|
||||||
|
externalSession({ id: "sess-remote", source: "remote-1" }),
|
||||||
|
externalSession({ id: "sess-nocwd", cwd: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||||
|
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("the external prompt")).toBeNull();
|
||||||
|
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("fires attach with the run id of the row that was clicked", () => {
|
it("fires attach with the run id of the row that was clicked", () => {
|
||||||
const { spies } = renderSwitcher();
|
const { spies } = renderSwitcher();
|
||||||
openModal();
|
openModal();
|
||||||
|
|||||||
@@ -201,9 +201,47 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists everything when no lane is selected", async () => {
|
it("starts the resume immediately when a session is picked", async () => {
|
||||||
const { api } = await import("../../../lib/api");
|
const { api } = await import("../../../lib/api");
|
||||||
renderSetup({ laneCwd: undefined });
|
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||||
|
sessions: [
|
||||||
|
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||||
|
|
||||||
|
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "sess-in-lane" })
|
||||||
|
);
|
||||||
|
// No prompt typed, no Run click - the pick itself is the start, and it
|
||||||
|
// carries the session's own cwd rather than the form's.
|
||||||
|
expect(spies.onStart).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/Users/tester" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists everything when no lane is selected and cwd is empty", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "" });
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ describe("TerminalView", () => {
|
|||||||
expect(writeMock).toHaveBeenCalledWith("hello");
|
expect(writeMock).toHaveBeenCalledWith("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
const bytes = new TextEncoder().encode("hello-binary").buffer;
|
||||||
|
ws.onmessage?.({ data: bytes });
|
||||||
|
expect(writeMock).toHaveBeenCalledWith("hello-binary");
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards terminal keystrokes as outgoing WS sends", () => {
|
it("forwards terminal keystrokes as outgoing WS sends", () => {
|
||||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||||
"kind.adopted": "adopted",
|
"kind.adopted": "adopted",
|
||||||
"kind.managed": "managed",
|
"kind.managed": "managed",
|
||||||
|
"laneDetail.hide": "Hide details",
|
||||||
|
"laneDetail.show": "Lane details",
|
||||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||||
"locks.held_one": "{{count}} lock held",
|
"locks.held_one": "{{count}} lock held",
|
||||||
"locks.held_other": "{{count}} locks held",
|
"locks.held_other": "{{count}} locks held",
|
||||||
@@ -120,6 +122,10 @@
|
|||||||
"features.archived": "archived",
|
"features.archived": "archived",
|
||||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||||
"proof.ticketReport": "Task report",
|
"proof.ticketReport": "Task report",
|
||||||
|
"splitView.emptyPane": "No lane selected for this pane.",
|
||||||
|
"splitView.paneLaneLabel": "Pane lane selector",
|
||||||
|
"splitView.paneCount": "{{count}} pane",
|
||||||
|
"splitView.pickLane": "Pick a lane",
|
||||||
"statusDead": "DEAD",
|
"statusDead": "DEAD",
|
||||||
"title": "Lanes",
|
"title": "Lanes",
|
||||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||||
|
|||||||
@@ -116,7 +116,9 @@
|
|||||||
"runs": {
|
"runs": {
|
||||||
"allSessionsLink": "See all Claude Code sessions →",
|
"allSessionsLink": "See all Claude Code sessions →",
|
||||||
"attached": "Attached to existing run",
|
"attached": "Attached to existing run",
|
||||||
"scopeNote": "Only shows runs you started from this dashboard.",
|
"externalBadge": "external",
|
||||||
|
"externalHint": "Started outside the dashboard, so there is no terminal to attach to. Resume opens a new tmux-backed `claude --resume` of this session in its folder.",
|
||||||
|
"scopeNote": "Runs started from this dashboard, plus Claude Code sessions running outside it.",
|
||||||
"started": "Started {{when}}",
|
"started": "Started {{when}}",
|
||||||
"switcher": "Active runs",
|
"switcher": "Active runs",
|
||||||
"switcherEmpty": "No active runs",
|
"switcherEmpty": "No active runs",
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
|
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
|
||||||
"checkNow": "Check now",
|
"checkNow": "Check now",
|
||||||
"checking": "Checking...",
|
"checking": "Checking...",
|
||||||
"checkError": "Could not check for updates"
|
"checkError": "Could not check for updates",
|
||||||
|
"updateNow": "Update now",
|
||||||
|
"updating": "Updating...",
|
||||||
|
"restarting": "Rebuilt — restarting the dashboard...",
|
||||||
|
"applyError": "Could not apply the update",
|
||||||
|
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||||
"kind.adopted": "đã nhận",
|
"kind.adopted": "đã nhận",
|
||||||
"kind.managed": "được quản lý",
|
"kind.managed": "được quản lý",
|
||||||
|
"laneDetail.hide": "Ẩn chi tiết",
|
||||||
|
"laneDetail.show": "Chi tiết lane",
|
||||||
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
||||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||||
@@ -120,6 +122,10 @@
|
|||||||
"features.archived": "đã lưu trữ",
|
"features.archived": "đã lưu trữ",
|
||||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||||
|
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||||
|
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||||
|
"splitView.paneCount": "{{count}} ô",
|
||||||
|
"splitView.pickLane": "Chọn lane",
|
||||||
"statusDead": "ĐÃ CHẾT",
|
"statusDead": "ĐÃ CHẾT",
|
||||||
"title": "Làn đường",
|
"title": "Làn đường",
|
||||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
||||||
|
|||||||
@@ -115,7 +115,9 @@
|
|||||||
"runs": {
|
"runs": {
|
||||||
"allSessionsLink": "Xem tất cả phiên Claude Code →",
|
"allSessionsLink": "Xem tất cả phiên Claude Code →",
|
||||||
"attached": "Đã gắn vào run đang chạy",
|
"attached": "Đã gắn vào run đang chạy",
|
||||||
"scopeNote": "Chỉ hiển thị các run bạn khởi chạy từ dashboard này.",
|
"externalBadge": "ngoài dashboard",
|
||||||
|
"externalHint": "Phiên này khởi chạy ngoài dashboard nên không có terminal để attach. Resume sẽ mở một `claude --resume` mới trong tmux tại đúng thư mục đó.",
|
||||||
|
"scopeNote": "Các run khởi chạy từ dashboard này, cùng những phiên Claude Code đang chạy bên ngoài.",
|
||||||
"started": "Bắt đầu lúc {{when}}",
|
"started": "Bắt đầu lúc {{when}}",
|
||||||
"switcher": "Run đang chạy",
|
"switcher": "Run đang chạy",
|
||||||
"switcherEmpty": "Không có run đang chạy",
|
"switcherEmpty": "Không có run đang chạy",
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
|
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
|
||||||
"checkNow": "Kiểm tra ngay",
|
"checkNow": "Kiểm tra ngay",
|
||||||
"checking": "Đang kiểm tra...",
|
"checking": "Đang kiểm tra...",
|
||||||
"checkError": "Không thể kiểm tra cập nhật"
|
"checkError": "Không thể kiểm tra cập nhật",
|
||||||
|
"updateNow": "Cập nhật ngay",
|
||||||
|
"updating": "Đang cập nhật...",
|
||||||
|
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
|
||||||
|
"applyError": "Không thể áp dụng cập nhật",
|
||||||
|
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* @file splitViewStorage.test.ts
|
||||||
|
* @description Tests for the splitViewStorage module.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
readSplitViewState,
|
||||||
|
writeSplitViewState,
|
||||||
|
defaultSplitViewState,
|
||||||
|
} from "../splitViewStorage";
|
||||||
|
|
||||||
|
describe("splitViewStorage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the default state when nothing is stored", () => {
|
||||||
|
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to a single unselected pane", () => {
|
||||||
|
expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a written state", () => {
|
||||||
|
writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] });
|
||||||
|
expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default when stored JSON is malformed", () => {
|
||||||
|
localStorage.setItem("ccam.workspace.splitView", "{not json");
|
||||||
|
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default when the stored layout is not 1, 2, or 4", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"ccam.workspace.splitView",
|
||||||
|
JSON.stringify({ layout: 3, paneLaneIds: [] })
|
||||||
|
);
|
||||||
|
expect(readSplitViewState()).toEqual(defaultSplitViewState());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -557,6 +557,23 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* POST /api/updates/apply - fast-forward, rebuild, and restart.
|
||||||
|
*
|
||||||
|
* Only acts when the checkout is fast-forwardable (`tracking_canonical`
|
||||||
|
* or `fork_or_diverged_tracking`); a 409 response means it declined
|
||||||
|
* (`applied: false`, `reason` explains why). On success the server
|
||||||
|
* restarts itself right after responding, so the caller should poll
|
||||||
|
* `status()` until it answers again rather than expect the connection
|
||||||
|
* to outlive the call.
|
||||||
|
*
|
||||||
|
* @returns {@link UpdateStatusPayload} with `applied` set.
|
||||||
|
*/
|
||||||
|
apply: () =>
|
||||||
|
request<UpdateStatusPayload>("/updates/apply", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────── Stats API ────────────────────────────────
|
// ──────────────────────────────── Stats API ────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @file splitViewStorage.ts
|
||||||
|
* @description Persists the Workspace page's split-terminal layout (1/2/4
|
||||||
|
* panes) and each pane's chosen lane id to localStorage, so the layout
|
||||||
|
* survives a page reload. Follows the same read/write-with-fallback
|
||||||
|
* convention as useTheme.ts.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SplitLayout = 1 | 2 | 4;
|
||||||
|
|
||||||
|
export interface SplitViewState {
|
||||||
|
layout: SplitLayout;
|
||||||
|
paneLaneIds: (number | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "ccam.workspace.splitView";
|
||||||
|
|
||||||
|
export function defaultSplitViewState(): SplitViewState {
|
||||||
|
return { layout: 1, paneLaneIds: [null] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLayout(value: unknown): value is SplitLayout {
|
||||||
|
return value === 1 || value === 2 || value === 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidState(value: unknown): value is SplitViewState {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
isValidLayout(v.layout) &&
|
||||||
|
Array.isArray(v.paneLaneIds) &&
|
||||||
|
v.paneLaneIds.every((id) => id === null || typeof id === "number")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSplitViewState(): SplitViewState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return defaultSplitViewState();
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isValidState(parsed) ? parsed : defaultSplitViewState();
|
||||||
|
} catch {
|
||||||
|
return defaultSplitViewState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSplitViewState(state: SplitViewState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / disabled storage */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1275,6 +1275,14 @@ export interface UpdateStatusPayload {
|
|||||||
/** Set instead of a normal result when the remote fetch itself failed
|
/** Set instead of a normal result when the remote fetch itself failed
|
||||||
* (e.g. offline) - the message text explains it in user-facing terms. */
|
* (e.g. offline) - the message text explains it in user-facing terms. */
|
||||||
fetch_error?: string;
|
fetch_error?: string;
|
||||||
|
/** Present only on the response from `POST /api/updates/apply` - true once
|
||||||
|
* the checkout was fast-forwarded and rebuilt (the process is about to
|
||||||
|
* restart itself). Absent from `status`/`check` responses. */
|
||||||
|
applied?: boolean;
|
||||||
|
/** Set alongside `applied: false` on an `/apply` response that declined to
|
||||||
|
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
|
||||||
|
* detached HEAD, where there's no safe automatic move). */
|
||||||
|
reason?: "up_to_date" | "not_fast_forwardable";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── Terminal run status ─────
|
// ───── Terminal run status ─────
|
||||||
|
|||||||
+405
-756
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @file Workspace.laneCwd.test.tsx
|
||||||
|
* @description The console's working directory must be the selected lane's own
|
||||||
|
* `cwd` — on first paint (the page auto-selects the first lane) and after every
|
||||||
|
* lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
|
||||||
|
* the run would be started in the previously selected lane's folder.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
|
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||||
|
// fixtures it reads have to be lifted with it.
|
||||||
|
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
|
||||||
|
const laneStub = (id: number, cwd: string) => ({
|
||||||
|
id,
|
||||||
|
title: `lane-${id}`,
|
||||||
|
cwd,
|
||||||
|
branch: null,
|
||||||
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
|
pipeline: "default",
|
||||||
|
session_id: null,
|
||||||
|
run_id: null,
|
||||||
|
stage: "idle",
|
||||||
|
stage_since: null,
|
||||||
|
status: "idle",
|
||||||
|
gate_decision: null,
|
||||||
|
ci_status: null,
|
||||||
|
needs_action: null,
|
||||||
|
links: {},
|
||||||
|
stages: {},
|
||||||
|
notes: null,
|
||||||
|
pipeline_name: "Default",
|
||||||
|
pipeline_nodes: [],
|
||||||
|
progress: 0,
|
||||||
|
stage_seconds: null,
|
||||||
|
last_event_seconds: null,
|
||||||
|
liveness: "idle",
|
||||||
|
detected_stage: null,
|
||||||
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
|
active_feature_id: null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
|
||||||
|
LANE_A: laneStub(1, "/workspace/alpha"),
|
||||||
|
LANE_B: laneStub(2, "/workspace/beta"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
run: {
|
||||||
|
list: r({ items: [] }),
|
||||||
|
history: r({ items: [] }),
|
||||||
|
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||||
|
cwds: r({ items: [HOME] }),
|
||||||
|
files: r({ items: [] }),
|
||||||
|
start: r({ id: "run-1", status: "running" }),
|
||||||
|
get: r({ id: "run-1", status: "running" }),
|
||||||
|
},
|
||||||
|
lanes: {
|
||||||
|
list: r({
|
||||||
|
lanes: [LANE_A, LANE_B],
|
||||||
|
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||||
|
}),
|
||||||
|
pipelines: r({ pipelines: [] }),
|
||||||
|
git: r({ available: false }),
|
||||||
|
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||||
|
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||||
|
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||||
|
},
|
||||||
|
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/eventBus", () => ({
|
||||||
|
eventBus: {
|
||||||
|
subscribe: () => () => {},
|
||||||
|
publish: () => {},
|
||||||
|
onConnection: () => () => {},
|
||||||
|
connected: true,
|
||||||
|
setConnected: () => {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Workspace } from "../Workspace";
|
||||||
|
|
||||||
|
class ObserverStub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver =
|
||||||
|
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwdInput(): HTMLInputElement {
|
||||||
|
return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Workspace — the console cwd follows the selected lane", () => {
|
||||||
|
it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/run"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps the cwd when another lane is selected in the strip", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/run"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("lane-tile-2"));
|
||||||
|
await settle();
|
||||||
|
expect(cwdInput().value).toBe(LANE_B.cwd);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("lane-tile-1"));
|
||||||
|
await settle();
|
||||||
|
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
@@ -633,3 +633,58 @@ describe("Workspace — proof gallery", () => {
|
|||||||
expect(gallery).toBeNull();
|
expect(gallery).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split terminal view", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
|
||||||
|
await renderWorkspace();
|
||||||
|
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
|
||||||
|
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
|
||||||
|
await renderWorkspace();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
|
||||||
|
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switching to 4-pane layout renders four panes", async () => {
|
||||||
|
await renderWorkspace();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the layout and pane selections to localStorage across remounts", async () => {
|
||||||
|
const { unmount } = await renderWorkspace();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||||
|
await settle();
|
||||||
|
const selects = screen.getAllByTestId("pane-lane-select");
|
||||||
|
const select = selects[1];
|
||||||
|
expect(select).toBeDefined();
|
||||||
|
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
|
||||||
|
await settle();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
await renderWorkspace();
|
||||||
|
const persistedSelects = screen.getAllByTestId("pane-lane-select");
|
||||||
|
expect(persistedSelects).toHaveLength(2);
|
||||||
|
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unselected when a persisted lane id no longer exists", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"ccam.workspace.splitView",
|
||||||
|
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
|
||||||
|
);
|
||||||
|
await renderWorkspace();
|
||||||
|
// Lane 9999 doesn't exist, so it falls back to null (unselected).
|
||||||
|
// The second pane is already null. Both render as pane-empty.
|
||||||
|
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5762,180 +5762,295 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
class="flex min-h-0 flex-1 gap-4"
|
||||||
data-testid="lane-strip"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-sm text-fg-muted"
|
|
||||||
>
|
|
||||||
No lanes yet. Create one from a working directory:
|
|
||||||
|
|
||||||
<code>
|
|
||||||
ccam lanes add --cwd $(pwd)
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex min-h-0 flex-col gap-2"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
data-testid="console-body"
|
data-testid="lane-strip"
|
||||||
>
|
>
|
||||||
<header
|
<p
|
||||||
class="flex items-start gap-3"
|
class="text-sm text-fg-muted"
|
||||||
>
|
>
|
||||||
<div
|
No lanes yet. Create one from a working directory:
|
||||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
<code>
|
||||||
<svg
|
ccam lanes add --cwd $(pwd)
|
||||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
</code>
|
||||||
fill="none"
|
</p>
|
||||||
height="24"
|
</div>
|
||||||
stroke="currentColor"
|
<div
|
||||||
stroke-linecap="round"
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
stroke-linejoin="round"
|
>
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<polygon
|
|
||||||
points="6 3 20 12 6 21 6 3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
class="text-lg font-semibold text-fg-primary"
|
|
||||||
>
|
|
||||||
Run Claude
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
|
||||||
/>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted max-w-3xl"
|
|
||||||
>
|
|
||||||
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
|
||||||
disabled=""
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-list-ordered w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M10 12h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 18h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 6h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 10h2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 6h1v4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Active runs
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-xl border border-border bg-surface-1"
|
class="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
aria-pressed="true"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
1 pane
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
2 pane
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
4 pane
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||||
|
data-testid="console-body"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||||
>
|
>
|
||||||
<button
|
<svg
|
||||||
aria-pressed="true"
|
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||||
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
fill="none"
|
||||||
title="Start a fresh Claude Code session."
|
height="24"
|
||||||
type="button"
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
New session
|
<polygon
|
||||||
</button>
|
points="6 3 20 12 6 21 6 3"
|
||||||
<button
|
/>
|
||||||
aria-pressed="false"
|
</svg>
|
||||||
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
|
||||||
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Resume existing session
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-4 py-3 border-b border-border"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
|
||||||
>
|
|
||||||
Prompt
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
|
||||||
placeholder="Ask Claude anything…"
|
|
||||||
rows="5"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="mt-1 text-[10px] text-fg-muted"
|
class="min-w-0 flex-1"
|
||||||
>
|
>
|
||||||
Cmd+Enter / Ctrl+Enter to send
|
|
||||||
· / for slash commands · @ for file references
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Working directory
|
|
||||||
</label>
|
|
||||||
<div
|
<div
|
||||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<h1
|
||||||
|
class="text-lg font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
Run Claude
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||||
|
/>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-xs text-fg-muted max-w-3xl"
|
||||||
|
>
|
||||||
|
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||||
|
disabled=""
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-list-ordered w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10 12h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 18h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 6h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 10h2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 6h1v4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Active runs
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-border bg-surface-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-pressed="true"
|
||||||
|
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
||||||
|
title="Start a fresh Claude Code session."
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
||||||
|
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Resume existing session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 border-b border-border"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
||||||
|
>
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||||
|
placeholder="Ask Claude anything…"
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mt-1 text-[10px] text-fg-muted"
|
||||||
|
>
|
||||||
|
Cmd+Enter / Ctrl+Enter to send
|
||||||
|
· / for slash commands · @ for file references
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Working directory
|
||||||
|
</label>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder="Type to search or paste an absolute path…"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="space-y-1.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
Inherit from settings
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 9 6 6 6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Permission mode
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
acceptEdits (recommended)
|
||||||
|
</span>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5947,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
d="m6 9 6 6 6-6"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
</button>
|
||||||
autocomplete="off"
|
|
||||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
||||||
placeholder="Type to search or paste an absolute path…"
|
|
||||||
spellcheck="false"
|
|
||||||
type="text"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label
|
||||||
<label
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
>
|
||||||
>
|
Thinking effort
|
||||||
Model
|
</label>
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="space-y-1.5"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
>
|
>
|
||||||
@@ -5981,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="truncate"
|
class="truncate"
|
||||||
>
|
>
|
||||||
Inherit from settings
|
Default (model decides)
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
@@ -6003,109 +6106,35 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Permission mode
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
acceptEdits (recommended)
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 9 6 6 6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Thinking effort
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
Default (model decides)
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 9 6 6 6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled=""
|
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
class="lucide lucide-play w-3.5 h-3.5"
|
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||||
fill="none"
|
/>
|
||||||
height="24"
|
<button
|
||||||
stroke="currentColor"
|
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
stroke-linecap="round"
|
disabled=""
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<polygon
|
<svg
|
||||||
points="6 3 20 12 6 21 6 3"
|
class="lucide lucide-play w-3.5 h-3.5"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
height="24"
|
||||||
Run
|
stroke="currentColor"
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="6 3 20 12 6 21 6 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1485,7 +1485,7 @@ GET /api/run/:id Run handle (returns live run state)
|
|||||||
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||||
```
|
```
|
||||||
|
|
||||||
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the `claude` command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
||||||
|
|
||||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `ccam
|
|||||||
| `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` |
|
| `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` |
|
||||||
| `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
| `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
||||||
| `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
|
| `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
|
||||||
| `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command** — the dashboard never restarts itself. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
| `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. When the checkout is fast-forwardable, the dashboard UI can also self-apply (`POST /api/updates/apply`: pull, rebuild, self-restart) instead of running the command by hand. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
||||||
| `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
|
| `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
|
||||||
| `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
|
| `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
|
||||||
| `ccam version` | Print the ccam version (also `--version` / `-v`) |
|
| `ccam version` | Print the ccam version (also `--version` / `-v`) |
|
||||||
|
|||||||
@@ -310,10 +310,31 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
|||||||
- **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.
|
- **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.
|
||||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||||
|
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`ccam.workspace.splitView`) across reloads.
|
||||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||||
|
|
||||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||||
|
|
||||||
|
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `ccam-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
|
||||||
|
|
||||||
|
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
|
||||||
|
|
||||||
|
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. Type into the tmux terminal once it's attached.
|
||||||
|
|
||||||
|
### Active runs list
|
||||||
|
|
||||||
|
The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first:
|
||||||
|
|
||||||
|
1. live in-memory tmux runs from `GET /api/run`,
|
||||||
|
2. persisted dashboard runs from `GET /api/run/history`,
|
||||||
|
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||||
|
|
||||||
|
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||||
|
|
||||||
|
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
|
||||||
|
|
||||||
|
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `ccam-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `ccam lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `ccam lanes shell` answered `200` while doing nothing at all.
|
||||||
|
|
||||||
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
||||||
|
|
||||||
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ output goes to `client-build.log` next to it; the server's own output goes to
|
|||||||
| Command | Does |
|
| Command | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, MCP build freshness, UI bundle |
|
| `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, MCP build freshness, UI bundle |
|
||||||
| `/ccam-update` | Reinstall dependencies and restart the server against the current plugin version (`plugin-bootstrap.js --force`) |
|
| `/ccam-update` | Pull the latest `ccam` release via `claude plugin marketplace update` + `claude plugin update ccam@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
|
||||||
| `/ccam-open` | Build the UI bundle if missing, then print the dashboard URL |
|
| `/ccam-open` | Build the UI bundle if missing, then print the dashboard URL |
|
||||||
|
|
||||||
### Where things live
|
### Where things live
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
# Split terminal view for the Workspace console
|
||||||
|
|
||||||
|
**Status:** approved 2026-08-14.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`Workspace.tsx` renders exactly one lane's run console at a time: a single
|
||||||
|
`RunSetup`/`TerminalView` switcher (client/src/pages/Workspace.tsx:789-824)
|
||||||
|
driven by page-level state (`selectedLaneId`, `prompt`, `cwd`, `model`,
|
||||||
|
`permissionMode`, `effort`, `resumeSession`, `handle`, `busy`, `activeRuns`,
|
||||||
|
`runHistory`, `cwdSuggestions`). Lanes are independent working directories
|
||||||
|
that can each have their own live tmux/PTY session running concurrently on
|
||||||
|
the server (`server/lib/pty-attach.js`), but the dashboard can only show one
|
||||||
|
at a time — comparing two lanes' output means switching back and forth.
|
||||||
|
|
||||||
|
The user wants to view multiple lanes' terminals side by side: 1 pane (today's
|
||||||
|
behavior), 2 panes (left/right), or 4 panes (2x2 grid).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Extract a self-contained `LaneConsolePane` component.** Move the existing
|
||||||
|
RunSetup/TerminalView switcher and all its state out of `Workspace.tsx` into
|
||||||
|
its own component that owns one lane's run lifecycle independently. Each
|
||||||
|
pane gets its own `laneId` (chosen via a dropdown in the pane header, listing
|
||||||
|
all lanes, not just ones with an active run) and manages its own
|
||||||
|
prompt/cwd/model/permissionMode/effort/resumeSession/handle/busy/activeRuns/
|
||||||
|
runHistory state — nothing is shared across panes.
|
||||||
|
|
||||||
|
Workspace keeps a `paneLaneIds: (number | null)[]` array sized to the current
|
||||||
|
layout (1, 2, or 4) and renders that many `LaneConsolePane` instances in a
|
||||||
|
CSS grid. This is the only viable approach given the existing state model is
|
||||||
|
single-lane; the alternative (keeping one shared state object indexed by
|
||||||
|
lane) would require rewriting every handler in Workspace.tsx to be
|
||||||
|
lane-aware and is a much larger, riskier diff for the same result.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
A layout toggle (1 / 2 / 4 buttons) sits next to the existing console
|
||||||
|
header. Grid via CSS:
|
||||||
|
|
||||||
|
- **1**: full width — identical to today.
|
||||||
|
- **2**: `grid-cols-2` — left/right.
|
||||||
|
- **4**: `grid-cols-2 grid-rows-2` — four corners.
|
||||||
|
|
||||||
|
Each pane has a small header with a lane-select dropdown. If the selected
|
||||||
|
lane has no active run, the pane shows a compact `RunSetup` (reused
|
||||||
|
component, same as today's pre-run form) so the user can start one directly
|
||||||
|
from the pane. If it has an active run, the pane shows `TerminalView` as
|
||||||
|
today.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The chosen layout mode and each pane's selected `laneId` are saved to
|
||||||
|
`localStorage` (e.g. key `ccam.workspace.splitView`) and restored on next
|
||||||
|
visit to Workspace. If a persisted lane no longer exists, that pane falls
|
||||||
|
back to unselected (dropdown placeholder).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No server/API changes — this is purely a client-side rendering feature.
|
||||||
|
Each lane's run already exists independently server-side; this just lets
|
||||||
|
the UI display more than one at once.
|
||||||
|
- No synchronized input across panes (typing in one pane's terminal does not
|
||||||
|
affect others) — each `TerminalView` keeps its own independent WebSocket
|
||||||
|
connection, unchanged from today's single-instance behavior.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `client/src/pages/__tests__/Workspace.test.tsx` currently mocks
|
||||||
|
`TerminalView` and exercises the single-console flow; update it (or add a
|
||||||
|
sibling test file) to cover: layout toggle, per-pane lane dropdown,
|
||||||
|
starting a run from within a pane, and multiple panes rendering
|
||||||
|
independent `TerminalView`/`RunSetup` instances.
|
||||||
|
- Run `npm run test:client` before considering this done.
|
||||||
@@ -6328,6 +6328,35 @@ paths:
|
|||||||
error:
|
error:
|
||||||
code: UPDATE_CHECK_FAILED
|
code: UPDATE_CHECK_FAILED
|
||||||
message: 'git fetch failed: network unreachable'
|
message: 'git fetch failed: network unreachable'
|
||||||
|
/api/updates/apply:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Updates
|
||||||
|
summary: Pull, rebuild, and restart the dashboard when fast-forwardable
|
||||||
|
operationId: applyUpdate
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Checkout fast-forwarded and rebuilt; the process is about to self-restart
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Same shape as GET /api/updates/status, plus applied: true.'
|
||||||
|
'409':
|
||||||
|
description: Declined - nothing to apply, or the checkout isn't safely fast-forwardable
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable".'
|
||||||
|
'500':
|
||||||
|
description: Update apply failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/alerts:
|
/api/alerts:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
---
|
---
|
||||||
description: Build the dashboard UI if needed and print its URL
|
description: Rebuild the dashboard UI and print its URL
|
||||||
---
|
---
|
||||||
|
|
||||||
Build the dashboard bundle if it is not there yet, then print the URL. The
|
Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
|
||||||
bootstrap already builds it on session start, so this is usually a no-op — use
|
stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
|
||||||
it to force a rebuild, or to finish the build if the bootstrap's own attempt
|
it) never serves silently. Also finishes the build if the bootstrap's own
|
||||||
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
attempt failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js"
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" --force
|
||||||
```
|
```
|
||||||
|
|
||||||
The first run installs the client toolchain and takes a few minutes; later runs
|
Install + build takes a few minutes if the client toolchain isn't already
|
||||||
print the URL immediately. No server restart is needed — the server already
|
installed; otherwise the rebuild itself takes ~10-15s. No server restart is
|
||||||
serves from that directory.
|
needed — the server already serves from that directory.
|
||||||
|
|
||||||
Then help the user open it:
|
Then help the user open it:
|
||||||
|
|
||||||
@@ -25,5 +25,4 @@ uname -s
|
|||||||
- `Linux` → `xdg-open <url>`
|
- `Linux` → `xdg-open <url>`
|
||||||
- otherwise → tell them to open the URL in a browser.
|
- otherwise → tell them to open the URL in a browser.
|
||||||
|
|
||||||
Keep the output to a few lines. Pass `--force` to the script only if the user
|
Keep the output to a few lines.
|
||||||
asks for a rebuild.
|
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
---
|
---
|
||||||
description: Refresh CCAM's runtime dependencies and restart the dashboard server
|
description: Pull the latest ccam plugin version and refresh the dashboard runtime
|
||||||
---
|
---
|
||||||
|
|
||||||
Reinstall the runtime dependencies and restart the dashboard server against the
|
Pull the newest `ccam` release from its marketplace, then reinstall the runtime
|
||||||
currently installed plugin version. Use this after a plugin update, or when
|
dependencies and restart the dashboard server against it. Use this to get a new
|
||||||
`/ccam-doctor` reports missing dependencies or a dead server.
|
dashboard build, or when `/ccam-doctor` reports missing dependencies or a dead
|
||||||
|
server.
|
||||||
|
|
||||||
This stops the running dashboard server before starting the new one. Say so, then
|
First, find which marketplace this install's `ccam` came from and pull the
|
||||||
run:
|
latest version into the plugin cache:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTALLED=$(claude plugin list 2>/dev/null | grep -o 'ccam@[^ ]*' | head -1)
|
||||||
|
MARKETPLACE="${INSTALLED#ccam@}"
|
||||||
|
if [ -z "$MARKETPLACE" ]; then
|
||||||
|
echo "ccam is not installed as a plugin (checkout install?) - nothing to update this way."
|
||||||
|
else
|
||||||
|
claude plugin marketplace update "$MARKETPLACE"
|
||||||
|
claude plugin update "ccam@$MARKETPLACE" -y
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
That downloads the new version into `~/.claude/plugins/cache/` but does not
|
||||||
|
take effect in *this* session — Claude Code only re-resolves `${CLAUDE_PLUGIN_ROOT}`
|
||||||
|
on `/reload-plugins` (a REPL-only action, not scriptable from a command). Say
|
||||||
|
so plainly: **tell the user to run `/reload-plugins` now**, then either
|
||||||
|
re-run `/ccam-update` (this time the bootstrap step below runs against the new
|
||||||
|
version) or just start a new session — `plugin-bootstrap.js` compares the
|
||||||
|
recorded state against the current plugin version on every `SessionStart` and
|
||||||
|
rebuilds automatically if they differ.
|
||||||
|
|
||||||
|
Then, whether or not a plugin update was just pulled, refresh the runtime
|
||||||
|
against whatever `${CLAUDE_PLUGIN_ROOT}` currently resolves to. This stops the
|
||||||
|
running dashboard server before starting the new one. Say so, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js" --force
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js" --force
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
|
||||||
|
* old dashboard PID until it exits (server/index.js's own SIGTERM handler
|
||||||
|
* has a 5s force-exit backstop, so this never waits forever), then starts a
|
||||||
|
* fresh server from the same entry point and exits itself.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
const [, , pidArg, entry] = process.argv;
|
||||||
|
const pid = parseInt(pidArg, 10);
|
||||||
|
|
||||||
|
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
|
||||||
|
const POLL_MS = 200;
|
||||||
|
const MAX_WAIT_MS = 20_000;
|
||||||
|
|
||||||
|
function isAlive(p) {
|
||||||
|
try {
|
||||||
|
process.kill(p, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const child = spawn(process.execPath, [entry], {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitAndRestart(waited = 0) {
|
||||||
|
if (isAlive(pid) && waited < MAX_WAIT_MS) {
|
||||||
|
setTimeout(() => waitAndRestart(waited + POLL_MS), POLL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitAndRestart();
|
||||||
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
|
||||||
|
// session running THIS script instead of the system Claude Code CLI. Tests
|
||||||
|
// that mock tmux's own exec calls (to simulate a stuck/live session) still
|
||||||
|
// spawn this real process underneath — without the stub, that spawn launches
|
||||||
|
// the actual `claude` binary and, because the mock replaces the app's own
|
||||||
|
// kill-session call, the real process is never actually terminated, leaking
|
||||||
|
// a live tmux session + CLI process for good. Returns the restore function.
|
||||||
|
function stubClaudeBinary(name) {
|
||||||
|
const bin = path.join(ROOT, `${name}-bin`);
|
||||||
|
const claude = path.join(bin, "claude");
|
||||||
|
fs.mkdirSync(bin, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
claude,
|
||||||
|
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
||||||
|
);
|
||||||
|
fs.chmodSync(claude, 0o755);
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||||
|
return () => {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForProvisioning(id) {
|
async function waitForProvisioning(id) {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
let response;
|
let response;
|
||||||
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
fs.writeFileSync(sentinel, "still here\n");
|
fs.writeFileSync(sentinel, "still here\n");
|
||||||
|
|
||||||
// Start a run for the lane
|
// Start a run for the lane
|
||||||
|
const restorePath = stubClaudeBinary("await-timeout");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
||||||
assert.equal(started.status, 200);
|
assert.equal(started.status, 200);
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -848,6 +872,15 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The mocked kill-session above only fools the app's own check — the
|
||||||
|
// real tmux session + claude stub spawned above is still alive and
|
||||||
|
// must be killed for real, or it leaks past this test run.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||||
});
|
});
|
||||||
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
const lane = await createManagedLane("start-twice");
|
const lane = await createManagedLane("start-twice");
|
||||||
|
|
||||||
// Start a run for the lane
|
// Start a run for the lane
|
||||||
|
const restorePath = stubClaudeBinary("start-twice");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||||
assert.equal(started.status, 200);
|
assert.equal(started.status, 200);
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
assert.equal(after.body.lane.run_id, runId);
|
assert.equal(after.body.lane.run_id, runId);
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The real tmux session behind the "first" run is never reset/killed
|
||||||
|
// in this test, mocked or otherwise — kill it for real so it doesn't
|
||||||
|
// leak past this test run.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||||
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
const lane = await adoptedLane("release-moved-on");
|
const lane = await adoptedLane("release-moved-on");
|
||||||
|
|
||||||
// Create a run for this lane.
|
// Create a run for this lane.
|
||||||
|
const restorePath = stubClaudeBinary("release-moved-on");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -1161,6 +1205,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
assert.equal(after.status, "running");
|
assert.equal(after.status, "running");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The app never calls kill-session here (healing preserves the "live"
|
||||||
|
// run) — kill the real tmux session directly so it doesn't leak.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1169,6 +1221,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
const lane = await adoptedLane("release-stale-run");
|
const lane = await adoptedLane("release-stale-run");
|
||||||
|
|
||||||
// Start a run for this lane.
|
// Start a run for this lane.
|
||||||
|
const restorePath = stubClaudeBinary("release-stale-run");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -1195,6 +1248,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The app believes the session is already gone and never calls
|
||||||
|
// kill-session — kill the real tmux session directly so it doesn't leak.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ describe("syncMcp — reading and relocating", () => {
|
|||||||
`${lane.cwd}/.playwright-mcp/profiles/default`
|
`${lane.cwd}/.playwright-mcp/profiles/default`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the lane's own cwd when source_repo is null (adopted lane)", async () => {
|
||||||
|
const lane = makeLane(null);
|
||||||
|
writeClaudeJson({ [lane.cwd]: { mcpServers: { playwright: { command: "npx", args: [] } } } });
|
||||||
|
const result = await laneMcp.syncMcp(lane);
|
||||||
|
assert.deepEqual(result.servers, ["playwright"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("syncMcp — Playwright output-dir pinning", () => {
|
describe("syncMcp — Playwright output-dir pinning", () => {
|
||||||
|
|||||||
@@ -58,15 +58,46 @@ describe("pty-run", () => {
|
|||||||
assert.ok(newSessionCall.includes("opus"));
|
assert.ok(newSessionCall.includes("opus"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawnRun is a no-op (adopts) when the tmux session already exists", () => {
|
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
|
if (args[0] === "display-message") return "claude\n";
|
||||||
return ""; // has-session succeeds → already running
|
return ""; // has-session succeeds → already running
|
||||||
});
|
});
|
||||||
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
|
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
|
||||||
assert.equal(handle.id, "ccam-lane-7");
|
assert.equal(handle.id, "ccam-lane-7");
|
||||||
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
||||||
|
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
|
||||||
|
const calls = [];
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === "display-message") return "bash\n";
|
||||||
|
return ""; // has-session succeeds → session exists, pane is a shell
|
||||||
|
});
|
||||||
|
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
||||||
|
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
||||||
|
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
|
||||||
|
assert.ok(literal, "expected a literal send-keys with the command line");
|
||||||
|
assert.match(literal[4], /^'claude' .*'--resume' 'abc12345'$/);
|
||||||
|
assert.ok(
|
||||||
|
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
|
||||||
|
"expected the command to be submitted"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
|
||||||
|
let literal = null;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "display-message") return "zsh\n";
|
||||||
|
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
|
||||||
|
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawnRun with resumeSessionId passes --resume in argv", () => {
|
it("spawnRun with resumeSessionId passes --resume in argv", () => {
|
||||||
|
|||||||
@@ -90,12 +90,17 @@ describe("POST /api/updates/check", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removed POST /api/updates/apply", () => {
|
describe("POST /api/updates/apply", () => {
|
||||||
it("returns 404 because self-update has been removed", async () => {
|
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
|
||||||
|
// The test repo checkout has no update pending (or isn't fast-forwardable
|
||||||
|
// from a bare test run), so this exercises the decline path only — it must
|
||||||
|
// never touch the working tree or process in a test run.
|
||||||
const res = await httpFetch("/api/updates/apply", {
|
const res = await httpFetch("/api/updates/apply", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
});
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 409);
|
||||||
|
assert.equal(res.body.applied, false);
|
||||||
|
assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,8 +120,9 @@ function excludeFromGit(laneDir, line) {
|
|||||||
* @returns {{servers: string[], profilesSeeded: string[]}}
|
* @returns {{servers: string[], profilesSeeded: string[]}}
|
||||||
*/
|
*/
|
||||||
async function syncMcp(lane) {
|
async function syncMcp(lane) {
|
||||||
const sourceServers = readSourceMcpServers(lane.source_repo);
|
const sourceRepo = lane.source_repo || lane.cwd;
|
||||||
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd);
|
const sourceServers = readSourceMcpServers(sourceRepo);
|
||||||
|
const relocated = relocate(sourceServers, sourceRepo, lane.cwd);
|
||||||
pinPlaywrightOutputDir(relocated, lane.cwd);
|
pinPlaywrightOutputDir(relocated, lane.cwd);
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -130,7 +131,7 @@ async function syncMcp(lane) {
|
|||||||
);
|
);
|
||||||
excludeFromGit(lane.cwd, ".mcp.json");
|
excludeFromGit(lane.cwd, ".mcp.json");
|
||||||
|
|
||||||
const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd);
|
const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
|
||||||
|
|
||||||
return { servers: Object.keys(relocated), profilesSeeded };
|
return { servers: Object.keys(relocated), profilesSeeded };
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-21
@@ -9,6 +9,10 @@
|
|||||||
* out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
|
* out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
|
||||||
* on the next read instead of leaving a ghost "running" row.
|
* on the next read instead of leaving a ghost "running" row.
|
||||||
*
|
*
|
||||||
|
* Start/Resume is create-or-reuse: when the lane's tmux session already
|
||||||
|
* exists but its pane sits at a shell prompt, the argv is typed into that
|
||||||
|
* pane instead of being dropped on the floor by a silent adopt.
|
||||||
|
*
|
||||||
* Every session is named `ccam-lane-<laneId>` so a real terminal can attach
|
* Every session is named `ccam-lane-<laneId>` so a real terminal can attach
|
||||||
* to the exact same session (`tmux attach -t ccam-lane-<id>`, or
|
* to the exact same session (`tmux attach -t ccam-lane-<id>`, or
|
||||||
* `ccam lanes shell`) — that's the whole point: the dashboard both creates
|
* `ccam lanes shell`) — that's the whole point: the dashboard both creates
|
||||||
@@ -30,6 +34,8 @@ try {
|
|||||||
const RUN_ID_RE = /^ccam-lane-(\d+)$/;
|
const RUN_ID_RE = /^ccam-lane-(\d+)$/;
|
||||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||||
|
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||||
|
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||||
|
|
||||||
function runIdForLane(laneId) {
|
function runIdForLane(laneId) {
|
||||||
return `ccam-lane-${laneId}`;
|
return `ccam-lane-${laneId}`;
|
||||||
@@ -99,31 +105,44 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
const id = runIdForLane(laneId);
|
const id = runIdForLane(laneId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
|
||||||
|
|
||||||
|
const record = () => {
|
||||||
|
if (!dashboardRuns) return;
|
||||||
|
dashboardRuns.recordRun({
|
||||||
|
id,
|
||||||
|
sessionId: resumeSessionId || null,
|
||||||
|
mode: null,
|
||||||
|
cwd,
|
||||||
|
model: model || null,
|
||||||
|
permissionMode: permissionMode || "acceptEdits",
|
||||||
|
effort: effort || null,
|
||||||
|
resumeSessionId: resumeSessionId || null,
|
||||||
|
prompt: initialPrompt || "",
|
||||||
|
status: "running",
|
||||||
|
startedAt,
|
||||||
|
endedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
laneId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!tmux.hasSession(id)) {
|
if (!tmux.hasSession(id)) {
|
||||||
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
|
|
||||||
tmux.newSession({ name: id, cwd, argv });
|
tmux.newSession({ name: id, cwd, argv });
|
||||||
if (dashboardRuns) {
|
record();
|
||||||
dashboardRuns.recordRun({
|
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||||
id,
|
// The session exists but its pane is sitting at a bare shell prompt — a
|
||||||
sessionId: resumeSessionId || null,
|
// `ccam lanes shell`, or a `claude` that already exited. Adopting it
|
||||||
mode: null,
|
// silently here would swallow the whole request: a Resume would spawn no
|
||||||
cwd,
|
// `--resume` and an initial prompt would never be typed, while the API
|
||||||
model: model || null,
|
// still answered 200. Run the argv in the pane the user already sees
|
||||||
permissionMode: permissionMode || "acceptEdits",
|
// instead of erroring or opening a second session.
|
||||||
effort: effort || null,
|
tmux.sendCommand(id, argv);
|
||||||
resumeSessionId: resumeSessionId || null,
|
record();
|
||||||
prompt: initialPrompt || "",
|
|
||||||
status: "running",
|
|
||||||
startedAt,
|
|
||||||
endedAt: null,
|
|
||||||
exitCode: null,
|
|
||||||
laneId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Already running: adopt silently, same convention as this repo's server
|
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||||
// port-adoption logic — no error, no duplicate session.
|
// silently, same convention as this repo's server port-adoption logic — no
|
||||||
|
// error, no duplicate session. Attaching shows the user what is running.
|
||||||
|
|
||||||
return getRun(id);
|
return getRun(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @file Triggers the one self-restart path this dashboard has: after
|
||||||
|
* `POST /api/updates/apply` fast-forwards and rebuilds the checkout, the
|
||||||
|
* running process needs to relaunch itself on the new code. Reuses the
|
||||||
|
* existing SIGTERM graceful-shutdown path in `server/index.js` (closes
|
||||||
|
* websockets, drains the HTTP server, closes the DB) instead of duplicating
|
||||||
|
* it, and hands the "wait for the old PID to actually die, then spawn the
|
||||||
|
* replacement" job to a detached helper script — the dying process can't do
|
||||||
|
* that itself without racing its own replacement for the port.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} [root] repo root the helper should run from.
|
||||||
|
*/
|
||||||
|
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
|
||||||
|
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||||
|
const entry = process.argv[1] || path.join(root, "server", "index.js");
|
||||||
|
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
|
||||||
|
cwd: root,
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
helper.unref();
|
||||||
|
process.kill(process.pid, "SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scheduleRestart };
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
|
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
|
||||||
* shell string — every call is `execFileSync("tmux", [...argv])` with an
|
* shell string — every call is `execFileSync("tmux", [...argv])` with an
|
||||||
* explicit argument array (matches this repo's rule for git in worktree.js).
|
* explicit argument array (matches this repo's rule for git in worktree.js).
|
||||||
|
* The one place a command line is composed is `sendCommand`, which types into
|
||||||
|
* an existing pane's shell: there the shell IS the consumer, so every argument
|
||||||
|
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -39,6 +42,36 @@ function newSession({ name, cwd, argv }) {
|
|||||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]);
|
execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The command currently running in the session's active pane (`bash`, `zsh`,
|
||||||
|
* `claude`, …). Null when tmux can't answer — callers treat that as "unknown,
|
||||||
|
* don't touch the pane".
|
||||||
|
*/
|
||||||
|
function paneCommand(name) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
|
||||||
|
function shellQuote(arg) {
|
||||||
|
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type `argv` into an existing session's pane and press Enter. Only ever
|
||||||
|
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
|
||||||
|
* the string literally so no character is read as a tmux key name.
|
||||||
|
*/
|
||||||
|
function sendCommand(name, argv) {
|
||||||
|
execImpl(["send-keys", "-t", name, "-l", argv.map(shellQuote).join(" ")]);
|
||||||
|
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Idempotent — a session that's already gone is not an error. */
|
/** Idempotent — a session that's already gone is not an error. */
|
||||||
function killSession(name) {
|
function killSession(name) {
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +107,8 @@ function isTmuxAvailable() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
hasSession,
|
hasSession,
|
||||||
newSession,
|
newSession,
|
||||||
|
paneCommand,
|
||||||
|
sendCommand,
|
||||||
killSession,
|
killSession,
|
||||||
listSessions,
|
listSessions,
|
||||||
isTmuxAvailable,
|
isTmuxAvailable,
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ function execGit(cwd, args, opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
||||||
|
* {@link applyUpdate} runs after fast-forwarding. */
|
||||||
|
function execNpm(cwd, args, opts = {}) {
|
||||||
|
const timeout = opts.timeout ?? 300_000;
|
||||||
|
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
npmCmd,
|
||||||
|
args,
|
||||||
|
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) reject(new Error(stderr || err.message || String(err)));
|
||||||
|
else resolve(String(stdout).trim());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function listRemotes(gitRoot) {
|
async function listRemotes(gitRoot) {
|
||||||
try {
|
try {
|
||||||
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
||||||
@@ -273,4 +291,47 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
|
/**
|
||||||
|
* Fast-forwards the checkout to the canonical remote and rebuilds, for the
|
||||||
|
* "Update now" button — the one path in this dashboard that mutates the
|
||||||
|
* working tree and restarts the process on its own initiative (everywhere
|
||||||
|
* else, `server/routes/updates.js` only ever prints a command for the user
|
||||||
|
* to run). Only acts when {@link getUpdatesStatus} reports a situation that
|
||||||
|
* is actually fast-forwardable (`tracking_canonical` or
|
||||||
|
* `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
|
||||||
|
* alone since there's no safe automatic move.
|
||||||
|
*
|
||||||
|
* @param {string} [gitRoot]
|
||||||
|
* @returns {Promise<object>} the refreshed status, plus `applied` (and
|
||||||
|
* `reason` when `applied` is false).
|
||||||
|
*/
|
||||||
|
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||||
|
const status = await getUpdatesStatus(gitRoot);
|
||||||
|
if (!status.update_available) {
|
||||||
|
return { ...status, applied: false, reason: "up_to_date" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status.situation !== "tracking_canonical" &&
|
||||||
|
status.situation !== "fork_or_diverged_tracking"
|
||||||
|
) {
|
||||||
|
return { ...status, applied: false, reason: "not_fast_forwardable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(gitRoot);
|
||||||
|
if (status.situation === "tracking_canonical") {
|
||||||
|
await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
|
||||||
|
} else {
|
||||||
|
await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
|
||||||
|
await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await execNpm(root, ["run", "setup"]);
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
await execNpm(root, ["run", "build"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await getUpdatesStatus(root, { skipFetch: true });
|
||||||
|
return { ...refreshed, applied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };
|
||||||
|
|||||||
@@ -2440,6 +2440,48 @@ function createOpenApiSpec() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/updates/apply": {
|
||||||
|
post: {
|
||||||
|
tags: ["Updates"],
|
||||||
|
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
|
||||||
|
operationId: "applyUpdate",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
description: "Same shape as GET /api/updates/status, plus applied: true.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description:
|
||||||
|
"Declined - nothing to apply, or the checkout isn't safely fast-forwardable",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Update apply failed",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/alerts": {
|
"/api/alerts": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Alerts"],
|
tags: ["Alerts"],
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file HTTP routes for dashboard upstream-update detection. The dashboard never
|
* @file HTTP routes for dashboard upstream-update detection, plus the one
|
||||||
* restarts itself — users copy the printed command and run it in their terminal.
|
* self-applying path: POST /apply fast-forwards, rebuilds, and restarts the
|
||||||
|
* process on the user's explicit click (see `server/lib/update-check.js`'s
|
||||||
|
* `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the
|
||||||
|
* dashboard only ever prints a command for the user to run themselves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const { getUpdatesStatus } = require("../lib/update-check");
|
const { getUpdatesStatus, applyUpdate } = require("../lib/update-check");
|
||||||
|
const { scheduleRestart } = require("../lib/self-restart");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -37,4 +41,36 @@ router.post("/check", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/apply", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await applyUpdate();
|
||||||
|
if (!result.applied) {
|
||||||
|
res.status(409).json({
|
||||||
|
...result,
|
||||||
|
error: {
|
||||||
|
code: "UPDATE_NOT_APPLICABLE",
|
||||||
|
message:
|
||||||
|
result.reason === "up_to_date"
|
||||||
|
? "Already up to date."
|
||||||
|
: "Current branch can't be fast-forwarded automatically.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
try {
|
||||||
|
const { broadcast } = require("../websocket");
|
||||||
|
broadcast("update_status", { ...result, update_available: false });
|
||||||
|
} catch {
|
||||||
|
// WS not initialized (e.g. in isolated tests) — safe to ignore.
|
||||||
|
}
|
||||||
|
// After the response above, restart onto the freshly-built code.
|
||||||
|
setImmediate(() => scheduleRestart());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: { code: "UPDATE_APPLY_FAILED", message: err.message || String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user