import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { listProjects } from "../api/projects"; import type { Project } from "../types"; import { listRepositories, type GitRepository } from "../api/git_repositories"; import { getUserSessions, type Session, deleteInstance, stopInstance, checkInstanceHealth, recreateInstanceTunnel, } from "../api/sessions"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { getUserConfig, updateUserConfig } from "../api/settings"; import { Icon } from "../components/icon"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; import { SessionCard } from "../components/session-card"; import type { InstanceHealth } from "../api/sessions"; type SessionsStatus = "loading" | "ready" | "error"; export const SessionsPage = () => { const navigate = useNavigate(); const [status, setStatus] = useState("loading"); const [sessions, setSessions] = useState([]); const [lastSessionId, setLastSessionId] = useState(null); const [projects, setProjects] = useState([]); const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProject, setSelectedProject] = useState(""); const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); const [tunnelHealth, setTunnelHealth] = useState>({}); const [loadingSessionId, setLoadingSessionId] = useState(null); const [loadingAction, setLoadingAction] = useState(""); const loadSessions = useCallback(async () => { setStatus("loading"); try { const [sessionsData, config] = await Promise.all([ getUserSessions(), getUserConfig(), ]); setSessions(sessionsData); setLastSessionId(config.last_session_id ?? null); setStatus("ready"); } catch { setStatus("error"); } }, []); useEffect(() => { void loadSessions(); }, [loadSessions]); useEffect(() => { const loadProjects = async () => { try { const data = await listProjects(); setProjects(data); } catch { // ignore } }; void loadProjects(); }, []); useEffect(() => { const loadToolTypes = async () => { try { const data = await listToolTypes(); setToolTypes(data); } catch { // ignore } }; void loadToolTypes(); }, []); // Poll health every 30 seconds for active web-enabled instances useEffect(() => { const checkHealth = async () => { const activeSessions = sessions.filter( (s) => ["running", "starting", "unhealthy", "probing"].includes(s.status) && s.tool_type_interfaces?.includes("web") ); for (const session of activeSessions) { try { const health = await checkInstanceHealth( session.project_id, session.repository_id, session.id ); setTunnelHealth((prev) => ({ ...prev, [session.id]: health, })); } catch { setTunnelHealth((prev) => ({ ...prev, [session.id]: { healthy: false, container_status: "unknown", container_health: null, tunnel_status: "unreachable", tunnel_status_code: null, probe_status: "unknown", last_probe_output: null, error: "check failed", } as InstanceHealth, })); } } }; // Check immediately and then every 30 seconds void checkHealth(); const interval = setInterval(() => void checkHealth(), 30000); return () => clearInterval(interval); }, [sessions]); useEffect(() => { if (!selectedProject) { setRepositories([]); return; } const loadRepos = async () => { try { const data = await listRepositories(selectedProject); setRepositories(data); } catch { setRepositories([]); } }; void loadRepos(); }, [selectedProject]); const lastSession = useMemo( () => sessions.find((s) => s.id === lastSessionId) ?? null, [sessions, lastSessionId] ); const handleCreateSuccess = async (instance: { id: string }) => { await updateUserConfig({ last_session_id: instance.id }); setSelectedProject(""); await loadSessions(); }; const handleStop = async (session: Session) => { setLoadingSessionId(session.id); setLoadingAction("Stopping..."); try { await stopInstance(session.project_id, session.repository_id, session.id); await loadSessions(); } catch { // ignore } finally { setLoadingSessionId(null); setLoadingAction(""); } }; const handleDelete = async (session: Session) => { setLoadingSessionId(session.id); setLoadingAction("Deleting..."); try { await deleteInstance(session.project_id, session.repository_id, session.id); setDirtyDeleteSession(null); setDirtyDeleteFiles([]); // Remove from local state immediately setSessions((prev) => prev.filter((s) => s.id !== session.id)); } catch (error) { const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } }; if (axiosError.response?.status === 409) { const detail = axiosError.response.data?.detail; if (detail?.changed_files) { setDirtyDeleteSession(session); setDirtyDeleteFiles(detail.changed_files); return; } } } finally { setLoadingSessionId(null); setLoadingAction(""); } }; const handleForceDelete = async (session: Session) => { setLoadingSessionId(session.id); setLoadingAction("Force deleting..."); try { await deleteInstance(session.project_id, session.repository_id, session.id, true); setDirtyDeleteSession(null); setDirtyDeleteFiles([]); setSessions((prev) => prev.filter((s) => s.id !== session.id)); } catch { // ignore } finally { setLoadingSessionId(null); setLoadingAction(""); } }; const handleRecreateTunnel = async (session: Session) => { setLoadingSessionId(session.id); setLoadingAction("Recreating tunnel..."); try { await recreateInstanceTunnel( session.project_id, session.repository_id, session.id ); // Refresh sessions to get new URL await loadSessions(); } catch { // ignore } finally { setLoadingSessionId(null); setLoadingAction(""); } }; const handleOpen = (session: Session) => { if (session.url) { window.open(session.url, '_blank', 'noopener,noreferrer'); } else if (session.tool_type_interfaces?.includes("terminal")) { navigate(`/instances/${session.id}/terminal`); } else { navigate(`/projects/${session.project_id}`); } }; return (

Sessions

{status === "loading" &&

Loading sessions...

} {status === "error" && (

Failed to load sessions

)} {status === "ready" && ( <> {/* Last Session */} {lastSession && (

Last Session

)} {/* Session List */}
{loadingSessionId && (

{loadingAction}

)}
{/* Create Session */}

Create New Session

{ setSelectedProject(projectId); }} onSuccess={handleCreateSuccess} />
{/* Dirty Delete Confirmation Modal */} {dirtyDeleteSession && (
setDirtyDeleteSession(null)}>
e.stopPropagation()}>

Uncommitted Changes

The repository {dirtyDeleteSession.repository_name} has uncommitted changes. Deleting this session will permanently lose these changes.

Changed files:

    {dirtyDeleteFiles.map((file, idx) => (
  • {file}
  • ))}
)} )}
); };