Compare commits
5 Commits
bab19e2f36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 174c650624 | |||
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 |
@@ -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"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
|
* 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/
|
* these side by side (split terminal view). Owns its own prompt/cwd/model/
|
||||||
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
|
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
|
||||||
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`, and
|
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
|
||||||
* `activeRuns` are supplied as props because they are global, not
|
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
|
||||||
* lane-specific, and fetching them per pane would mean N redundant identical
|
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||||
* requests for an N-pane layout.
|
* requests for an N-pane layout.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -38,6 +38,9 @@ export interface LaneConsolePaneProps {
|
|||||||
binaryStatus: { found: boolean; path: string | null } | null;
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
activeRuns: RunListResponse | null;
|
activeRuns: RunListResponse | null;
|
||||||
|
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||||
|
* active-runs switcher alongside dashboard runs. */
|
||||||
|
externalSessions?: Session[];
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
defaultCwd?: string;
|
defaultCwd?: string;
|
||||||
onHasActiveRunChange?: (active: boolean) => void;
|
onHasActiveRunChange?: (active: boolean) => void;
|
||||||
@@ -52,6 +55,7 @@ export function LaneConsolePane({
|
|||||||
binaryStatus,
|
binaryStatus,
|
||||||
cwdSuggestions,
|
cwdSuggestions,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
defaultCwd,
|
defaultCwd,
|
||||||
onHasActiveRunChange,
|
onHasActiveRunChange,
|
||||||
@@ -340,6 +344,7 @@ export function LaneConsolePane({
|
|||||||
currentHandleId={handle?.id || null}
|
currentHandleId={handle?.id || null}
|
||||||
onAttach={attachToRun}
|
onAttach={attachToRun}
|
||||||
runHistory={runHistory}
|
runHistory={runHistory}
|
||||||
|
externalSessions={externalSessions}
|
||||||
onResumeFromHistory={onResumeFromHistory}
|
onResumeFromHistory={onResumeFromHistory}
|
||||||
onViewFromHistory={onViewFromHistory}
|
onViewFromHistory={onViewFromHistory}
|
||||||
onRefresh={refreshList}
|
onRefresh={refreshList}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+263
-194
@@ -5,6 +5,8 @@
|
|||||||
* the selected lane's pipeline map, and run configuration/console/history below.
|
* the selected lane's pipeline map, and run configuration/console/history below.
|
||||||
* Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
|
* Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
|
||||||
* When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
|
* When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
|
||||||
|
* Polls GET /api/sessions?status=active alongside the run list so Claude Code
|
||||||
|
* sessions started outside the dashboard also appear in the active-runs list.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +44,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||||
import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
|
import type { Lane, LaneFeature, LaneCounts, ProofFeature, Session, WSMessage } from "../lib/types";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
@@ -54,6 +56,36 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
|||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SplitLayoutToggle({
|
||||||
|
splitView,
|
||||||
|
setLayout,
|
||||||
|
}: {
|
||||||
|
splitView: SplitViewState;
|
||||||
|
setLayout: (layout: SplitLayout) => void;
|
||||||
|
}) {
|
||||||
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{([1, 2, 4] as const).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={splitView.layout === n}
|
||||||
|
onClick={() => setLayout(n)}
|
||||||
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
|
splitView.layout === n
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tLanes("splitView.paneCount", { count: n })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConsoleArea({
|
function ConsoleArea({
|
||||||
lanes,
|
lanes,
|
||||||
selectedLaneId,
|
selectedLaneId,
|
||||||
@@ -63,11 +95,13 @@ function ConsoleArea({
|
|||||||
binaryStatus,
|
binaryStatus,
|
||||||
cwdSuggestions,
|
cwdSuggestions,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
defaultCwd,
|
defaultCwd,
|
||||||
onHasActiveRunChange,
|
onHasActiveRunChange,
|
||||||
onLaneCreated,
|
onLaneCreated,
|
||||||
onLaneIdChange,
|
onLaneIdChange,
|
||||||
|
showToggle = true,
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
selectedLaneId: number | null;
|
selectedLaneId: number | null;
|
||||||
@@ -77,33 +111,17 @@ function ConsoleArea({
|
|||||||
binaryStatus: { found: boolean; path: string | null } | null;
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
activeRuns: RunListResponse | null;
|
activeRuns: RunListResponse | null;
|
||||||
|
externalSessions: Session[];
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
defaultCwd: string;
|
defaultCwd: string;
|
||||||
onHasActiveRunChange: (val: boolean) => void;
|
onHasActiveRunChange: (val: boolean) => void;
|
||||||
onLaneCreated: (lane: Lane) => void;
|
onLaneCreated: (lane: Lane) => void;
|
||||||
onLaneIdChange: (id: number | null) => void;
|
onLaneIdChange: (id: number | null) => void;
|
||||||
|
showToggle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5">
|
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||||
{([1, 2, 4] as const).map((n) => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={splitView.layout === n}
|
|
||||||
onClick={() => setLayout(n)}
|
|
||||||
className={`rounded border px-2 py-1 text-xs ${
|
|
||||||
splitView.layout === n
|
|
||||||
? "border-accent bg-accent/15 text-accent"
|
|
||||||
: "border-border text-fg-secondary hover:border-border-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tLanes("splitView.paneCount", { count: n })}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{splitView.layout === 1 ? (
|
{splitView.layout === 1 ? (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
@@ -114,6 +132,7 @@ function ConsoleArea({
|
|||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
|
externalSessions={externalSessions}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
onHasActiveRunChange={onHasActiveRunChange}
|
onHasActiveRunChange={onHasActiveRunChange}
|
||||||
@@ -135,6 +154,7 @@ function ConsoleArea({
|
|||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
|
externalSessions={externalSessions}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
/>
|
/>
|
||||||
@@ -166,6 +186,10 @@ export function Workspace() {
|
|||||||
// Run state kept at page level: shared across every pane, or drives the
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
// lane strip itself rather than any one pane's form.
|
// lane strip itself rather than any one pane's form.
|
||||||
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
||||||
|
// Claude Code sessions running outside the dashboard (a `claude` the user
|
||||||
|
// started in a terminal tab). They are not dashboard runs, so the run list
|
||||||
|
// would otherwise show nothing while two agents are working.
|
||||||
|
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
|
||||||
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
|
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -175,6 +199,11 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Split view state
|
// Split view state
|
||||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||||
|
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
|
||||||
|
// in single-pane view; in split view it starts collapsed so tall content
|
||||||
|
// never crowds the pane grid, and the user expands it on demand.
|
||||||
|
const [infoExpanded, setInfoExpanded] = useState(false);
|
||||||
|
const showInfo = splitView.layout === 1 || infoExpanded;
|
||||||
|
|
||||||
const setLayout = useCallback((layout: SplitLayout) => {
|
const setLayout = useCallback((layout: SplitLayout) => {
|
||||||
setSplitView((prev) => {
|
setSplitView((prev) => {
|
||||||
@@ -217,10 +246,6 @@ export function Workspace() {
|
|||||||
// `claude` is missing from PATH — leave the probe unresolved rather than
|
// `claude` is missing from PATH — leave the probe unresolved rather than
|
||||||
// showing a misleading "claude missing" banner for an unrelated fault.
|
// showing a misleading "claude missing" banner for an unrelated fault.
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.run
|
|
||||||
.list()
|
|
||||||
.then(setActiveRuns)
|
|
||||||
.catch(() => undefined);
|
|
||||||
api.lanes
|
api.lanes
|
||||||
.pipelines()
|
.pipelines()
|
||||||
.then((r) => setPipelineTemplates(r.pipelines))
|
.then((r) => setPipelineTemplates(r.pipelines))
|
||||||
@@ -301,6 +326,10 @@ export function Workspace() {
|
|||||||
.list()
|
.list()
|
||||||
.then(setActiveRuns)
|
.then(setActiveRuns)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
api.sessions
|
||||||
|
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
|
||||||
|
.then((r) => setExternalSessions(r.sessions))
|
||||||
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Background poll so the run list and history reflect external changes
|
// Background poll so the run list and history reflect external changes
|
||||||
@@ -308,6 +337,7 @@ export function Workspace() {
|
|||||||
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
||||||
// status flips appear within seconds without needing a manual refresh.
|
// status flips appear within seconds without needing a manual refresh.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
refreshList();
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
refreshList();
|
refreshList();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -420,7 +450,10 @@ export function Workspace() {
|
|||||||
try {
|
try {
|
||||||
const { lane } = await api.lanes.update(id, { pipeline });
|
const { lane } = await api.lanes.update(id, { pipeline });
|
||||||
await refreshLanes();
|
await refreshLanes();
|
||||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
// `stage` defaults to the DB sentinel "idle" until a driving session ever
|
||||||
|
// calls `ccam stage` — that's the normal state for most lanes and never
|
||||||
|
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
|
||||||
|
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||||
setLaneActionError(
|
setLaneActionError(
|
||||||
tLanes("pipelinePicker.stageMismatch", {
|
tLanes("pipelinePicker.stageMismatch", {
|
||||||
stage: lane.stage,
|
stage: lane.stage,
|
||||||
@@ -488,161 +521,218 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
|
{/* Lane list + detail live side by side so neither can cover the other:
|
||||||
snap so a dozen lanes stay in one row instead of a wall of cards. */}
|
the list is a scrolling column (holds any number of lanes), the
|
||||||
<div
|
detail/console area to its right never has to compete with it for
|
||||||
data-testid="lane-strip"
|
vertical space. */}
|
||||||
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
>
|
<div
|
||||||
{lanes.map((l) => (
|
data-testid="lane-strip"
|
||||||
<LaneStripCard
|
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
key={l.id}
|
>
|
||||||
lane={l}
|
{lanes.map((l) => (
|
||||||
selected={selectedLaneId === l.id}
|
<LaneStripCard
|
||||||
onSelect={() => setSelectedLaneId(l.id)}
|
key={l.id}
|
||||||
/>
|
lane={l}
|
||||||
))}
|
selected={selectedLaneId === l.id}
|
||||||
{!lanes.length && (
|
onSelect={() => setSelectedLaneId(l.id)}
|
||||||
<p className="text-sm text-fg-muted">
|
/>
|
||||||
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
|
))}
|
||||||
</p>
|
{!lanes.length && (
|
||||||
)}
|
<p className="text-sm text-fg-muted">
|
||||||
</div>
|
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* The selected lane's pipeline, full width — the thing you actually
|
{/* The selected lane's pipeline — the thing you actually come to this
|
||||||
come to this page to read. */}
|
page to read, beside the list rather than stacked under it. */}
|
||||||
{currentLane && (
|
{currentLane && (
|
||||||
<section data-testid="lane-detail" className="card p-4">
|
<section
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
data-testid="lane-detail"
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||||
{tLanes("cardId", { id: currentLane.id })}
|
>
|
||||||
</span>
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||||
{currentLane.title || currentLane.cwd}
|
{tLanes("laneHeader", {
|
||||||
</span>
|
id: currentLane.id,
|
||||||
<select
|
title: currentLane.title || currentLane.cwd,
|
||||||
data-testid="pipeline-picker"
|
pipeline: currentLane.pipeline_name,
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
})}
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
|
||||||
value={currentLane.pipeline}
|
|
||||||
disabled={!!viewedFeature}
|
|
||||||
title={
|
|
||||||
viewedFeature
|
|
||||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
|
||||||
>
|
|
||||||
{(pipelineTemplates.length
|
|
||||||
? pipelineTemplates
|
|
||||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
|
||||||
).map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
|
||||||
session ever calls `ccam stage` — that string collides with
|
|
||||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
|
||||||
actively running but has never declared a stage read as if it
|
|
||||||
were sitting idle. Show a distinct label instead of the raw
|
|
||||||
sentinel whenever it doesn't match any node this pipeline
|
|
||||||
actually has. */}
|
|
||||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
|
||||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
|
||||||
? currentLane.stage
|
|
||||||
: tLanes("stageUndeclared")}
|
|
||||||
</span>
|
|
||||||
{currentLane.detected_stage && (
|
|
||||||
<span
|
|
||||||
data-testid="detail-auto-stage"
|
|
||||||
title={currentLane.detected_signal || undefined}
|
|
||||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
|
||||||
>
|
|
||||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{features.length > 0 && (
|
{splitView.layout !== 1 && (
|
||||||
<select
|
<button
|
||||||
data-testid="feature-picker"
|
type="button"
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
data-testid="lane-detail-toggle"
|
||||||
value={viewedFeatureSlug ?? ""}
|
aria-expanded={infoExpanded}
|
||||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
onClick={() => setInfoExpanded((v) => !v)}
|
||||||
>
|
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||||
<option value="">{tLanes("features.live")}</option>
|
|
||||||
{features.map((f) => (
|
|
||||||
<option key={f.slug} value={f.slug}>
|
|
||||||
{f.slug}
|
|
||||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<LaneCard
|
|
||||||
lane={currentLane}
|
|
||||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
|
||||||
childWorktrees={lanes.filter(
|
|
||||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
|
||||||
)}
|
|
||||||
onSelectLane={setSelectedLaneId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<PipelineMap
|
|
||||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
|
||||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
|
||||||
/>
|
|
||||||
{viewedFeature && (
|
|
||||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
|
||||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{proofFeature &&
|
|
||||||
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
|
||||||
<div data-testid="proof-gallery" className="mt-2">
|
|
||||||
{proofFeature.ticket_report && (
|
|
||||||
<a
|
|
||||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-xs text-fg-muted"
|
|
||||||
>
|
>
|
||||||
{tLanes("proof.ticketReport")}
|
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||||
<div key={group} className="mt-1">
|
</div>
|
||||||
<span className="text-xs text-fg-muted">
|
</div>
|
||||||
{group} · {images.length}
|
{showInfo && (
|
||||||
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
|
{tLanes("cardId", { id: currentLane.id })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
data-testid="pipeline-picker"
|
||||||
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||||
|
value={currentLane.pipeline}
|
||||||
|
disabled={!!viewedFeature}
|
||||||
|
title={
|
||||||
|
viewedFeature
|
||||||
|
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||||
|
>
|
||||||
|
{(pipelineTemplates.length
|
||||||
|
? pipelineTemplates
|
||||||
|
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||||
|
).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||||
|
session ever calls `ccam stage` — that string collides with
|
||||||
|
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||||
|
actively running but has never declared a stage read as if it
|
||||||
|
were sitting idle. Show a distinct label instead of the raw
|
||||||
|
sentinel whenever it doesn't match any node this pipeline
|
||||||
|
actually has. */}
|
||||||
|
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||||
|
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||||
|
? currentLane.stage
|
||||||
|
: tLanes("stageUndeclared")}
|
||||||
|
</span>
|
||||||
|
{currentLane.detected_stage && (
|
||||||
|
<span
|
||||||
|
data-testid="detail-auto-stage"
|
||||||
|
title={currentLane.detected_signal || undefined}
|
||||||
|
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||||
|
>
|
||||||
|
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
)}
|
||||||
{images.slice(0, 8).map((img) => (
|
{features.length > 0 && (
|
||||||
<img
|
<select
|
||||||
key={img}
|
data-testid="feature-picker"
|
||||||
loading="lazy"
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||||
className="h-16 w-16 rounded object-cover"
|
value={viewedFeatureSlug ?? ""}
|
||||||
src={api.lanes.proof.imageUrl(
|
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||||
currentLane.id,
|
>
|
||||||
proofFeature.slug,
|
<option value="">{tLanes("features.live")}</option>
|
||||||
group,
|
{features.map((f) => (
|
||||||
img
|
<option key={f.slug} value={f.slug}>
|
||||||
)}
|
{f.slug}
|
||||||
alt={img}
|
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||||
/>
|
</option>
|
||||||
))}
|
))}
|
||||||
{images.length > 8 && (
|
</select>
|
||||||
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<LaneCard
|
||||||
|
lane={currentLane}
|
||||||
|
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||||
|
childWorktrees={lanes.filter(
|
||||||
|
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||||
|
)}
|
||||||
|
onSelectLane={setSelectedLaneId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<PipelineMap
|
||||||
|
nodes={
|
||||||
|
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||||
|
}
|
||||||
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||||
|
/>
|
||||||
|
{viewedFeature && (
|
||||||
|
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||||
|
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{proofFeature &&
|
||||||
|
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
||||||
|
<div data-testid="proof-gallery" className="mt-2">
|
||||||
|
{proofFeature.ticket_report && (
|
||||||
|
<a
|
||||||
|
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
{tLanes("proof.ticketReport")}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
||||||
|
<div key={group} className="mt-1">
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{group} · {images.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{images.slice(0, 8).map((img) => (
|
||||||
|
<img
|
||||||
|
key={img}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-16 w-16 rounded object-cover"
|
||||||
|
src={api.lanes.proof.imageUrl(
|
||||||
|
currentLane.id,
|
||||||
|
proofFeature.slug,
|
||||||
|
group,
|
||||||
|
img
|
||||||
|
)}
|
||||||
|
alt={img}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{images.length > 8 && (
|
||||||
|
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<ConsoleArea
|
||||||
|
lanes={lanes}
|
||||||
|
selectedLaneId={selectedLaneId}
|
||||||
|
splitView={splitView}
|
||||||
|
setLayout={setLayout}
|
||||||
|
setPaneLaneId={setPaneLaneId}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
externalSessions={externalSessions}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
|
onLaneCreated={(lane) =>
|
||||||
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
|
}
|
||||||
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
showToggle={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!currentLane && (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -652,6 +742,7 @@ export function Workspace() {
|
|||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
|
externalSessions={externalSessions}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
@@ -661,30 +752,8 @@ export function Workspace() {
|
|||||||
onLaneIdChange={setSelectedLaneId}
|
onLaneIdChange={setSelectedLaneId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{!currentLane && (
|
|
||||||
<div className="flex min-h-0 flex-col gap-2">
|
|
||||||
<ConsoleArea
|
|
||||||
lanes={lanes}
|
|
||||||
selectedLaneId={selectedLaneId}
|
|
||||||
splitView={splitView}
|
|
||||||
setLayout={setLayout}
|
|
||||||
setPaneLaneId={setPaneLaneId}
|
|
||||||
binaryStatus={binaryStatus}
|
|
||||||
cwdSuggestions={cwdSuggestions}
|
|
||||||
activeRuns={activeRuns}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
defaultCwd={defaultCwd}
|
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
|
||||||
onLaneCreated={(lane) =>
|
|
||||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
|
||||||
}
|
|
||||||
onLaneIdChange={setSelectedLaneId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addLaneOpen}
|
open={addLaneOpen}
|
||||||
|
|||||||
@@ -5762,205 +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 items-center gap-1.5"
|
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
|
data-testid="lane-strip"
|
||||||
>
|
>
|
||||||
<button
|
<p
|
||||||
aria-pressed="true"
|
class="text-sm text-fg-muted"
|
||||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
1 pane
|
No lanes yet. Create one from a working directory:
|
||||||
</button>
|
|
||||||
<button
|
<code>
|
||||||
aria-pressed="false"
|
ccam lanes add --cwd $(pwd)
|
||||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
</code>
|
||||||
type="button"
|
</p>
|
||||||
>
|
|
||||||
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>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
data-testid="console-body"
|
|
||||||
>
|
>
|
||||||
<header
|
|
||||||
class="flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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"
|
||||||
@@ -5972,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"
|
||||||
>
|
>
|
||||||
@@ -6006,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"
|
||||||
@@ -6028,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>
|
||||||
|
|||||||
@@ -315,6 +315,18 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
|||||||
|
|
||||||
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>`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
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`
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user