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",
|
||||||
|
|||||||
+111
-42
@@ -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,39 +56,16 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
|||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ConsoleArea({
|
function SplitLayoutToggle({
|
||||||
lanes,
|
|
||||||
selectedLaneId,
|
|
||||||
splitView,
|
splitView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setPaneLaneId,
|
|
||||||
binaryStatus,
|
|
||||||
cwdSuggestions,
|
|
||||||
activeRuns,
|
|
||||||
wsConnected,
|
|
||||||
defaultCwd,
|
|
||||||
onHasActiveRunChange,
|
|
||||||
onLaneCreated,
|
|
||||||
onLaneIdChange,
|
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
|
||||||
selectedLaneId: number | null;
|
|
||||||
splitView: SplitViewState;
|
splitView: SplitViewState;
|
||||||
setLayout: (layout: SplitLayout) => void;
|
setLayout: (layout: SplitLayout) => void;
|
||||||
setPaneLaneId: (index: number, id: number) => void;
|
|
||||||
binaryStatus: { found: boolean; path: string | null } | null;
|
|
||||||
cwdSuggestions: CwdSuggestion[];
|
|
||||||
activeRuns: RunListResponse | null;
|
|
||||||
wsConnected: boolean;
|
|
||||||
defaultCwd: string;
|
|
||||||
onHasActiveRunChange: (val: boolean) => void;
|
|
||||||
onLaneCreated: (lane: Lane) => void;
|
|
||||||
onLaneIdChange: (id: number | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{([1, 2, 4] as const).map((n) => (
|
{([1, 2, 4] as const).map((n) => (
|
||||||
<button
|
<button
|
||||||
@@ -104,6 +83,45 @@ function ConsoleArea({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleArea({
|
||||||
|
lanes,
|
||||||
|
selectedLaneId,
|
||||||
|
splitView,
|
||||||
|
setLayout,
|
||||||
|
setPaneLaneId,
|
||||||
|
binaryStatus,
|
||||||
|
cwdSuggestions,
|
||||||
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
|
wsConnected,
|
||||||
|
defaultCwd,
|
||||||
|
onHasActiveRunChange,
|
||||||
|
onLaneCreated,
|
||||||
|
onLaneIdChange,
|
||||||
|
showToggle = true,
|
||||||
|
}: {
|
||||||
|
lanes: Lane[];
|
||||||
|
selectedLaneId: number | null;
|
||||||
|
splitView: SplitViewState;
|
||||||
|
setLayout: (layout: SplitLayout) => void;
|
||||||
|
setPaneLaneId: (index: number, id: number) => void;
|
||||||
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
|
cwdSuggestions: CwdSuggestion[];
|
||||||
|
activeRuns: RunListResponse | null;
|
||||||
|
externalSessions: Session[];
|
||||||
|
wsConnected: boolean;
|
||||||
|
defaultCwd: string;
|
||||||
|
onHasActiveRunChange: (val: boolean) => void;
|
||||||
|
onLaneCreated: (lane: Lane) => void;
|
||||||
|
onLaneIdChange: (id: number | null) => void;
|
||||||
|
showToggle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||||
{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,11 +521,14 @@ 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
|
||||||
|
detail/console area to its right never has to compete with it for
|
||||||
|
vertical space. */}
|
||||||
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div
|
<div
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
>
|
>
|
||||||
{lanes.map((l) => (
|
{lanes.map((l) => (
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
@@ -509,17 +545,42 @@ export function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
||||||
|
data-testid="lane-detail"
|
||||||
|
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||||
|
{tLanes("laneHeader", {
|
||||||
|
id: currentLane.id,
|
||||||
|
title: currentLane.title || currentLane.cwd,
|
||||||
|
pipeline: currentLane.pipeline_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{splitView.layout !== 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-detail-toggle"
|
||||||
|
aria-expanded={infoExpanded}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showInfo && (
|
||||||
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
{tLanes("cardId", { id: currentLane.id })}
|
{tLanes("cardId", { id: currentLane.id })}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
|
||||||
{currentLane.title || currentLane.cwd}
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
data-testid="pipeline-picker"
|
data-testid="pipeline-picker"
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
@@ -592,7 +653,9 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<PipelineMap
|
<PipelineMap
|
||||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
nodes={
|
||||||
|
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||||
|
}
|
||||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||||
/>
|
/>
|
||||||
{viewedFeature && (
|
{viewedFeature && (
|
||||||
@@ -642,7 +705,9 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -652,6 +717,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}
|
||||||
@@ -659,13 +725,14 @@ export function Workspace() {
|
|||||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
}
|
}
|
||||||
onLaneIdChange={setSelectedLaneId}
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
showToggle={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentLane && (
|
{!currentLane && (
|
||||||
<div className="flex min-h-0 flex-col gap-2">
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -675,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}
|
||||||
@@ -685,6 +753,7 @@ export function Workspace() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addLaneOpen}
|
open={addLaneOpen}
|
||||||
|
|||||||
@@ -5762,7 +5762,10 @@ 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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
@@ -5776,7 +5779,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-col gap-2"
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
@@ -6136,6 +6139,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</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