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:
2026-08-18 09:49:03 +07:00
parent 2c29504c75
commit 174c650624
7 changed files with 181 additions and 21 deletions
@@ -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}
+84 -12
View File
@@ -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();
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+18 -5
View File
@@ -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}
+12
View File
@@ -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`