From 174c650624c0f4f697482d9742f7601ae05a0aa7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 18 Aug 2026 09:49:03 +0700 Subject: [PATCH] feat(run): show externally started Claude sessions in the active-runs list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/run/LaneConsolePane.tsx | 9 +- client/src/components/run/RunHistory.tsx | 96 ++++++++++++++++--- .../run/__tests__/RunHistory.test.tsx | 54 +++++++++++ client/src/i18n/locales/en/run.json | 4 +- client/src/i18n/locales/vi/run.json | 4 +- client/src/pages/Workspace.tsx | 23 ++++- docs/LANES.md | 12 +++ 7 files changed, 181 insertions(+), 21 deletions(-) diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 76d4558..0d4ee44 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -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ĩ @@ -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} diff --git a/client/src/components/run/RunHistory.tsx b/client/src/components/run/RunHistory.tsx index a39eee6..f217d74 100644 --- a/client/src/components/run/RunHistory.tsx +++ b/client/src/components/run/RunHistory.tsx @@ -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 ` 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ĩ */ @@ -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(); + const seenSessions = new Set(); 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 (
)} + {row.external && ( + + {t("runs.externalBadge")} + + )} {isCurrent && ( {t("runs.currentBadge", "current")} )} - {row.isLive && !isCurrent && ( + {row.isLive && !row.external && !isCurrent && (