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
+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" />