Files
headquarter/apps/web/src/pages/sessions.tsx
T
alex ce1a73abce feat: add styles and polish for unified session list components
- Add CSS styles for SessionCard and SessionList components
- Add responsive styles for mobile viewport
- Fix TypeScript errors (remove unused port property)
- Fix ESLint errors (remove unused imports and variables)

Refs: session-list-overhaul tasks 5-6
2026-05-24 12:07:37 +00:00

352 lines
11 KiB
TypeScript

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<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
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 (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <p className="muted">Loading sessions...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "ready" && (
<>
{/* Last Session */}
{lastSession && (
<div className="last-session-section">
<h2>Last Session</h2>
<SessionCard
session={lastSession}
onOpen={handleOpen}
onDelete={handleDelete}
isBusy={loadingSessionId === lastSession.id}
tunnelHealth={tunnelHealth[lastSession.id] || null}
/>
</div>
)}
{/* Session List */}
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
{loadingSessionId && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{loadingAction}</p>
</div>
</div>
)}
<SessionList
sessions={sessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={loadingSessionId}
tunnelHealth={tunnelHealth}
/>
</div>
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
<CreateSessionForm
projects={projects}
repositories={repositories}
toolTypes={toolTypes}
onProjectChange={(projectId) => {
setSelectedProject(projectId);
}}
onSuccess={handleCreateSuccess}
/>
</div>
{/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && (
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Uncommitted Changes</h3>
<p>
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
uncommitted changes. Deleting this session will permanently lose these
changes.
</p>
<div className="changed-files-list">
<h4>Changed files:</h4>
<ul>
{dirtyDeleteFiles.map((file, idx) => (
<li key={idx}>{file}</li>
))}
</ul>
</div>
<div className="modal-actions">
<button
className="secondary-button"
onClick={() => setDirtyDeleteSession(null)}
type="button"
>
Cancel
</button>
<button
className="danger-button"
onClick={() => void handleForceDelete(dirtyDeleteSession)}
type="button"
>
Force Delete
</button>
</div>
</div>
</div>
)}
</>
)}
</section>
);
};