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
* these side by side (split terminal view). Owns its own prompt/cwd/model/
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`, and
* `activeRuns` are supplied as props because they are global, not
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical
* requests for an N-pane layout.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -38,6 +38,9 @@ export interface LaneConsolePaneProps {
binaryStatus: { found: boolean; path: string | null } | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
* active-runs switcher alongside dashboard runs. */
externalSessions?: Session[];
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
@@ -52,6 +55,7 @@ export function LaneConsolePane({
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
@@ -340,6 +344,7 @@ export function LaneConsolePane({
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
+84 -12
View File
@@ -12,9 +12,16 @@
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions.
*
* Props only: no API call of its own. The page passes `activeRuns` and
* `runHistory` in and gets attach / resume / view / refresh back out through
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "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>
*/
@@ -34,6 +41,7 @@ import {
Eye,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
// Minimal StatusPill component (from deleted RunConsole)
function StatusPill({
@@ -74,6 +82,10 @@ export interface UnifiedRunRow {
startedAt: number;
endedAt: number | null;
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({
@@ -81,6 +93,7 @@ export function ActiveRunsSwitcher({
currentHandleId,
onAttach,
runHistory,
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onRefresh,
@@ -89,6 +102,9 @@ export function ActiveRunsSwitcher({
currentHandleId: string | null;
onAttach: (id: string) => void;
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;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
onRefresh: () => void;
@@ -111,14 +127,18 @@ export function ActiveRunsSwitcher({
};
}, [open]);
// Merge live in-memory handles + persistent history into one row list.
// Live entries dedupe past-history entries with the same id.
const rows: UnifiedRunRow[] = useMemo(() => {
// Merge live in-memory handles + persistent history + externally started
// sessions into one row list. Live entries dedupe past-history entries with
// 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 seen = new Set<string>();
const seenSessions = new Set<string>();
if (activeRuns) {
for (const r of activeRuns.items) {
seen.add(r.id);
if (r.sessionId) seenSessions.add(r.sessionId);
out.push({
id: r.id,
sessionId: r.sessionId,
@@ -135,6 +155,7 @@ export function ActiveRunsSwitcher({
for (const h of runHistory) {
if (seen.has(h.id)) continue;
seen.add(h.id);
if (h.session_id) seenSessions.add(h.session_id);
const startedTs = new Date(h.started_at).getTime() || 0;
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
out.push({
@@ -149,9 +170,49 @@ export function ActiveRunsSwitcher({
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);
return out;
}, [activeRuns, runHistory]);
return {
rows: out,
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
};
}, [activeRuns, runHistory, externalSessions]);
const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length;
@@ -196,7 +257,7 @@ export function ActiveRunsSwitcher({
setOpen(false);
onViewFromHistory(item);
}}
runHistory={runHistory}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
/>
@@ -467,8 +528,10 @@ function UnifiedRunRowView({
hour: "2-digit",
minute: "2-digit",
});
// Without mode distinction, offer resume for any finished run with a session
const canResume = !!row.sessionId && !row.isLive;
// Without mode distinction, offer resume for any finished run with a session.
// 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;
return (
<div
@@ -484,13 +547,21 @@ function UnifiedRunRowView({
{t("runs.liveBadge", "live")}
</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 && (
<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")}
</span>
)}
<span className="ml-auto inline-flex items-center gap-1.5">
{row.isLive && !isCurrent && (
{row.isLive && !row.external && !isCurrent && (
<button
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"
@@ -502,6 +573,7 @@ function UnifiedRunRowView({
{canResume && (
<button
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"
>
<RotateCcw className="w-3 h-3" />
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
import type { Session } from "../../../lib/types";
const LIVE_ID = "run-live";
const PAST_ID = "run-past";
@@ -67,6 +68,21 @@ const HEADLESS = historyItem({
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>> = {}) {
const spies = {
onAttach: vi.fn(),
@@ -188,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
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", () => {
const { spies } = renderSwitcher();
openModal();