import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '../api'; import { fmtId, capRef } from '../util'; import { CapabilitySearch } from './CapabilitySearch'; import type { Instance, SessionInfo, CapabilityResult } from '../types'; import type { SessionApi } from '../useSession'; import { markRegistrySessionViewed, sessionRegistryKeyFor, setRegistryActiveSession, upsertRegistrySessions, useSessionRegistry } from '../sessionRegistry'; import { useSessionSnapshotMonitor } from '../sessionMonitor'; export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000, refreshTick = 0 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number; refreshTick?: number; }) { const [instances, setInstances] = useState([]); const [sessions, setSessions] = useState([]); const [instId, setInstId] = useState(''); const [selectedSessionKey, setSelectedSessionKey] = useState(''); const [backendKey, setBackendKey] = useState(''); const [showPicker, setShowPicker] = useState(false); const [endingSession, setEndingSession] = useState(''); const [reconnectingInstance, setReconnectingInstance] = useState(''); const [sessionErr, setSessionErr] = useState(''); const [attachedInstanceId, setAttachedInstanceId] = useState(''); const [attachedSessionId, setAttachedSessionId] = useState(''); const instIdRef = useRef(''); const attachedRef = useRef(false); const attachedOwnerRef = useRef(''); const inventorySeqRef = useRef(0); const sessionsSeqRef = useRef(0); const missingAttachedPollsRef = useRef(0); const inputRef = useRef(null); const sessionRegistry = useSessionRegistry(); useSessionSnapshotMonitor(); useEffect(() => { instIdRef.current = instId; }, [instId]); useEffect(() => { attachedRef.current = session.state.attached; }, [session.state.attached]); const insertCap = (r: CapabilityResult) => { const sep = composer && !composer.endsWith(' ') ? ' ' : ''; setComposer(composer + sep + capRef(r.type, r.name)); setShowPicker(false); inputRef.current?.focus(); }; const refreshInventory = useCallback(async () => { const seq = inventorySeqRef.current + 1; inventorySeqRef.current = seq; const d = await api<{ instances: Instance[] }>('/api/inventory'); if (seq !== inventorySeqRef.current) return; const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.length); setInstances(sessionable); const currentId = instIdRef.current; let nextId = sessionable[0]?.id ?? ''; if (currentId && sessionable.some((i) => i.id === currentId)) nextId = currentId; else if (attachedRef.current && currentId) nextId = currentId; instIdRef.current = nextId; setInstId(nextId); setBackendKey((currentBackend) => { const selectedInstance = sessionable.find((i) => i.id === nextId) ?? sessionable[0]; if (currentBackend && selectedInstance?.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available !== false)) return currentBackend; if (attachedRef.current && currentBackend) return currentBackend; const firstBackend = selectedInstance?.session_backends.find((b) => b.available !== false) ?? selectedInstance?.session_backends[0]; return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : ''; }); }, []); useEffect(() => { attachedOwnerRef.current = attachedInstanceId || instanceIdFromAttachUrl(session.state.url); }, [attachedInstanceId, session.state.url]); const loadSessions = useCallback(async (id: string) => { if (!id) return; const seq = sessionsSeqRef.current + 1; sessionsSeqRef.current = seq; const d = await api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`); if (seq !== sessionsSeqRef.current || id !== instIdRef.current) return; const nextSessions = d.sessions ?? []; upsertRegistrySessions(nextSessions); setSessions(nextSessions); setSessionErr(''); setSelectedSessionKey((currentKey) => { if (currentKey && nextSessions.some((s) => sessionKey(s) === currentKey)) return currentKey; if (attachedRef.current && attachedOwnerRef.current === id && currentKey) return currentKey; return nextSessions[0] ? sessionKey(nextSessions[0]) : ''; }); }, []); useEffect(() => { let cancelled = false; let timer: number | undefined; let delay = refreshMs; const schedule = (ms: number) => { timer = window.setTimeout(tick, ms); }; const tick = async () => { if (cancelled) return; if (endingSession) { schedule(refreshMs); return; } try { const idBeforeInventory = instIdRef.current; await refreshInventory(); if (idBeforeInventory && idBeforeInventory === instIdRef.current) await loadSessions(instIdRef.current); delay = refreshMs; } catch (e) { if (!cancelled) setSessionErr((e as Error).message); delay = Math.min(Math.max(refreshMs, delay * 2), 30_000); } if (!cancelled) schedule(delay); }; tick(); return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [endingSession, loadSessions, refreshInventory, refreshMs, refreshTick]); useEffect(() => { if (!instId) return; // Clear the previous instance's rows immediately so a slow load never shows // the wrong instance's sessions in the nav during the switch. setSessions([]); setSessionErr(''); loadSessions(instId).catch((e) => setSessionErr((e as Error).message)); }, [instId, loadSessions]); const attached = session.state.attached; const requestedReplayRole = session.state.role === 'controller' ? 'controller' : 'observer'; const current = instances.find((i) => i.id === instId); const backends = current?.session_backends ?? []; const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available !== false) ?? backends[0]; const currentUnavailableReason = backends.find((b) => b.available === false)?.reason; const currentReconnectable = current ? isReconnectable(current) : false; const selectedSession = sessions.find((s) => sessionKey(s) === selectedSessionKey); const attachedOwner = attachedInstanceId || instanceIdFromAttachUrl(session.state.url); const attachedKey = attachedOwner && attachedSessionId ? `${attachedOwner}:${attachedSessionId}` : sessionKeyFromAttachUrl(session.state.url); const activeTarget = session.state.target ?? (attachedOwner && attachedSessionId ? { instanceId: attachedOwner, sessionId: attachedSessionId } : null); // Merge the currently-attached session into the nav even when the executor's // session-list API omits it. Host-runtime PTY sessions are not returned by // list_sessions (agentic-sandbox #500 follow-up), so a live, attached session // would otherwise render as "No sessions yet". The synthetic row reuses the // attach URL Cockpit already holds so selecting it re-attaches/replays. const displaySessions: SessionInfo[] = (attached && attachedOwner === instId && attachedSessionId && !sessions.some((s) => sessionKey(s) === attachedKey)) ? [...sessions, { id: attachedSessionId, instance_id: instId, attach_url: session.state.url ?? '', session_name: 'attached session', session_backend: selectedBackend?.backend, session_class: selectedBackend?.mode, }] : sessions; const send = () => { if (session.sendInput(composer, activeTarget)) setComposer(''); }; const attachToSession = (s: SessionInfo, role: 'controller' | 'observer') => { setAttachedInstanceId(s.instance_id || instId); setAttachedSessionId(String(s.id)); setRegistryActiveSession(s.instance_id || instId, String(s.id)); session.attach(s.attach_url, false, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) }); }; const replaySession = (s: SessionInfo, role: 'controller' | 'observer') => { setAttachedInstanceId(s.instance_id || instId); setAttachedSessionId(String(s.id)); setRegistryActiveSession(s.instance_id || instId, String(s.id)); session.replay(s.attach_url, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) }); }; const detachSession = () => { setAttachedInstanceId(''); setAttachedSessionId(''); setRegistryActiveSession(null, null); session.detach(); }; useEffect(() => { const selected = sessions.find((s) => sessionKey(s) === selectedSessionKey); if (selected) markRegistrySessionViewed(selected.instance_id, String(selected.id)); }, [selectedSessionKey, sessions]); useEffect(() => { if (!session.state.url) return; if (!attachedOwner || attachedOwner !== instId) return; const sessionStillListed = sessions.some((s) => sessionKey(s) === attachedKey); if (sessionStillListed) { missingAttachedPollsRef.current = 0; return; } if (sessions.length) { missingAttachedPollsRef.current += 1; if (missingAttachedPollsRef.current >= 2) detachSession(); } }, [attachedKey, attachedOwner, instId, session.state.url, sessions]); useEffect(() => { if (!current) return; const valid = current.session_backends.some((b) => `${b.mode}:${b.backend}` === backendKey); if (!valid) { const next = current.session_backends.find((b) => b.available) ?? current.session_backends[0]; setBackendKey(next ? `${next.mode}:${next.backend}` : ''); } }, [backendKey, current]); // Starting now routes through the shared picker (#1640/#1641) so this tab and the // dashboard verb share one params/clobber/error path. The selects below remain for // attaching to / observing / driving sessions that already exist. const endSelectedSession = async (target?: SessionInfo) => { const s = target ?? selectedSession; if (!current || !s) return; const label = s.session_name ?? s.id; if (!confirm(`End session ${label}? This closes the PTY and detaches connected views.`)) return; setEndingSession(s.id); setSessionErr(''); try { await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' }); if (sessionKey(s) === attachedKey) detachSession(); await loadSessions(current.id); } catch (e) { setSessionErr((e as Error).message); } finally { setEndingSession(''); } }; const reconnectCurrent = async () => { if (!current) return; setReconnectingInstance(current.id); setSessionErr(''); try { await api(`/api/instances/${encodeURIComponent(current.id)}/reconnect`, { method: 'POST' }); await refreshInventory(); await loadSessions(current.id); } catch (e) { setSessionErr((e as Error).message); } finally { setReconnectingInstance(''); } }; return ( <>

Workspace. Pick an instance on the left, then a session under it to observe or drive. Start a new session per instance, or open a live task from the Running fleet board.

{/* Persistent instances → sessions navigation (agentic-sandbox-style control screen, #1670). */}
{backends.length > 1 && ( <> )} {selectedSession ? sessionLabel(selectedSession) : '— no session selected —'} {currentReconnectable && ( )} {session.state.role && {session.state.role}}
{sessionErr &&

Session action failed: {sessionErr}

} {current && (

{current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted. {attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''} {selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''} {currentReconnectable ? ` Agent is unreachable while the runtime is still running. ${currentUnavailableReason ?? 'Reconnect can re-register the agent without restarting the instance.'}` : ''}

)}
{showPicker && (

Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)

)}
setComposer(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') send(); }} placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'} disabled={!session.isController} aria-label="Session input" />
); } function sessionLabel(s: SessionInfo): string { return s.session_name ?? fmtId(s.id); } function sessionMeta(s: SessionInfo): string { const backend = `${s.session_class ?? 'managed'}/${s.session_backend ?? 'tmux'}`; // v2 membership is authoritative; omit the viewer fragment when the executor // doesn't advertise membership rather than implying "0 viewers". if (!s.membership) return backend; const viewers = s.membership.attachment_count; return `${backend} · ${viewers} viewer${viewers === 1 ? '' : 's'}`; } function sessionHoldsController(s: SessionInfo): boolean { return (s.membership?.controllers.length ?? 0) > 0; } function sessionKey(s: SessionInfo): string { return `${s.instance_id}:${s.id}`; } function sessionKeyFromAttachUrl(url: string | null): string { const parts = sessionPartsFromAttachUrl(url); return parts ? `${parts.instanceId}:${parts.sessionId}` : ''; } function sessionPartsFromAttachUrl(url: string | null): { instanceId: string; sessionId: string } | null { if (!url) return null; const pattern = /\/agents\/([^/]+)\/sessions\/([^/]+)\/attach/; try { const parsed = new URL(url); const match = parsed.pathname.match(pattern); return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null; } catch { const match = url.match(pattern); return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null; } } function instanceIdFromAttachUrl(url: string | null): string { return sessionPartsFromAttachUrl(url)?.instanceId ?? ''; } function dedupeInstances(instances: Instance[]) { const seen = new Set(); return instances.filter((instance) => { if (seen.has(instance.id)) return false; seen.add(instance.id); return true; }); } // VM runtimes included per #1778 — the bridge signals the in-guest agent via // qemu-guest-agent, the container/docker path via docker exec. const RECONNECTABLE_RUNTIMES = ['docker', 'container', 'vm', 'qemu', 'kvm']; function isReconnectable(i: Instance): boolean { const runtime = String(i.runtime_posture?.kind ?? i.runtime).toLowerCase(); const running = String(i.state).toLowerCase() === 'running'; const agentMissing = i.agent_ready === false || i.session_backends?.some((b) => b.available === false); return running && RECONNECTABLE_RUNTIMES.includes(runtime) && Boolean(agentMissing); }