feat(run): show externally started Claude sessions in the active-runs list
The Workspace active-runs list only knew about runs this dashboard spawned, so two `claude` sessions started by hand in terminal tabs showed up nowhere — the list read "no active runs" while two agents were working. Poll GET /api/sessions?status=active alongside the run list and merge those sessions in as live rows, deduped against dashboard runs by session_id and filtered to local sources with a cwd (a remote-source or cwd-less session cannot be resumed on this machine). External rows get no Attach action: the dashboard owns no tmux session for them, so there is no PTY to bridge. They offer Resume, which reuses the existing ensure-lane + start-with-resumeSessionId path to spawn a new tmux-backed `claude --resume` in that folder — a second process on the same transcript, not a view of the original terminal.
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -93,6 +95,7 @@ function ConsoleArea({
|
|||||||
binaryStatus,
|
binaryStatus,
|
||||||
cwdSuggestions,
|
cwdSuggestions,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
externalSessions,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
defaultCwd,
|
defaultCwd,
|
||||||
onHasActiveRunChange,
|
onHasActiveRunChange,
|
||||||
@@ -108,6 +111,7 @@ 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;
|
||||||
@@ -128,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}
|
||||||
@@ -149,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}
|
||||||
/>
|
/>
|
||||||
@@ -180,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
|
||||||
);
|
);
|
||||||
@@ -236,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))
|
||||||
@@ -320,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
|
||||||
@@ -327,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);
|
||||||
@@ -706,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}
|
||||||
@@ -730,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}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
Reference in New Issue
Block a user