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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user