Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Fusion
2026-05-24 14:05:38 +02:00
5 changed files with 471 additions and 438 deletions
+238
View File
@@ -0,0 +1,238 @@
import { useState } from "react";
import type { Session } from "../api/sessions";
import { Icon } from "./icon";
export interface SessionCardProps {
session: Session;
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
isBusy?: boolean;
tunnelHealth?: {
healthy: boolean;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
} | null;
}
const statusConfig: Record<string, { color: string; label: string }> = {
running: { color: "green", label: "Running" },
building: { color: "yellow", label: "Building" },
starting: { color: "yellow", label: "Starting" },
probing: { color: "yellow", label: "Probing" },
pending: { color: "yellow", label: "Pending" },
stopped: { color: "gray", label: "Stopped" },
error: { color: "red", label: "Error" },
unhealthy: { color: "orange", label: "Unhealthy" },
};
export function SessionCard({
session,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const status = statusConfig[session.status] || { color: "gray", label: session.status };
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
const handleStop = () => {
if (showStopConfirm) {
setShowStopConfirm(false);
onStop?.(session);
} else {
setShowStopConfirm(true);
}
};
const handleDelete = () => {
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete?.(session);
} else {
setShowDeleteConfirm(true);
}
};
const handleCancelStop = () => setShowStopConfirm(false);
const handleCancelDelete = () => setShowDeleteConfirm(false);
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
return (
<article className="card session-card">
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
<h4>{session.display_name}</h4>
<div className="session-card-status-badges">
<span className={`status-badge ${status.color}`}>{status.label}</span>
{hasTunnelError && (
<span className="status-badge error">Tunnel Error</span>
)}
{hasAppError && (
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
)}
</div>
</div>
<p className="muted session-card-meta">
{session.tool_type_name}
{session.project_name && ` · ${session.project_name}`}
{session.repository_name && ` · ${session.repository_name}`}
</p>
{session.clone_mode && (
<p className="muted session-card-meta">
<Icon name="branch" size="sm" />
{session.clone_mode === "clone"
? `Clone${session.branch ? ` (${session.branch})` : ""}`
: "Mount"}
</p>
)}
{session.url && (
<p className="session-card-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
{session.port && session.status === "running" && (
<p className="muted session-card-meta">Port: {session.port}</p>
)}
{session.created_at && (
<p className="muted session-card-meta">
Created: {new Date(session.created_at).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</article>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Session } from "../api/sessions";
import { SessionCard } from "./session-card";
import type { InstanceHealth } from "../api/sessions";
export interface SessionListProps {
sessions: Session[];
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
actionBusyId?: string | null;
tunnelHealth?: Record<string, InstanceHealth>;
showGrouping?: boolean;
activeTitle?: string;
recentTitle?: string;
maxRecent?: number;
emptyMessage?: string;
}
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
const recentStatuses = ["stopped", "error"];
export function SessionList({
sessions,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
actionBusyId = null,
tunnelHealth = {},
showGrouping = true,
activeTitle = "Active Sessions",
recentTitle = "Recent Sessions",
maxRecent = 5,
emptyMessage = "No sessions",
}: SessionListProps) {
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
const recentSessions = sessions
.filter((s) => recentStatuses.includes(s.status))
.slice(0, maxRecent);
if (!showGrouping) {
return (
<div className="sessions-grid">
{sessions.length === 0 ? (
<p className="muted">{emptyMessage}</p>
) : (
sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))
)}
</div>
);
}
return (
<div className="session-list">
{/* Active Sessions */}
<div className="session-group">
<div className="session-group-header">
<h3>{activeTitle}</h3>
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="session-group">
<div className="session-group-header">
<h3>{recentTitle}</h3>
<span className="badge">{recentSessions.length}</span>
</div>
<div className="sessions-grid">
{recentSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
</div>
)}
</div>
);
}
+36 -91
View File
@@ -10,6 +10,8 @@ import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card";
type HomeStatus = "loading" | "ready" | "error";
@@ -31,8 +33,6 @@ export const HomePage = () => {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : [];
@@ -149,12 +149,7 @@ export const HomePage = () => {
};
const handleStop = async (session: SessionView) => {
if (stopConfirmId !== session.id) {
setStopConfirmId(session.id);
return;
}
setActionBusy(session.id);
setStopConfirmId(null);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
@@ -164,12 +159,7 @@ export const HomePage = () => {
};
const handleDelete = async (session: SessionView) => {
if (deleteConfirmId !== session.id) {
setDeleteConfirmId(session.id);
return;
}
setActionBusy(session.id);
setDeleteConfirmId(null);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setSessions((prev) => prev.filter((s) => s.id !== session.id));
@@ -190,6 +180,16 @@ export const HomePage = () => {
}
};
const handleStart = async (session: SessionView) => {
setActionBusy(session.id);
try {
await startInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack home-page">
<header className="home-hero card">
@@ -237,74 +237,20 @@ export const HomePage = () => {
<div className="page-header">
<div>
<p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2>
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
</div>
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions right now.</p>
) : (
<div className="home-session-grid">
{activeSessions.map((session) => {
const health = tunnelHealth[session.id];
const isUnhealthy = health && !health.healthy;
return (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<div className="row row-tight">
{isUnhealthy && (
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
{stopConfirmId === session.id ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" /> Stop
</button>
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
</div>
) : (
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<span className="confirm-text">Delete?</span>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" /> Delete
</button>
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
</div>
) : (
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</article>
);
})}
</div>
)}
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={actionBusy}
tunnelHealth={tunnelHealth}
showGrouping={false}
emptyMessage="No active sessions right now."
/>
</section>
<section className="card stack home-section">
@@ -350,25 +296,24 @@ export const HomePage = () => {
/>
</section>
{recentSessions.length > 0 && (
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2>
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
</div>
</div>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
</div>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
</article>
))}
</div>
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStart={handleStart}
onDelete={handleDelete}
actionBusyId={actionBusy}
showGrouping={false}
maxRecent={5}
emptyMessage="No recent sessions."
/>
</section>
)}
</>
+49 -324
View File
@@ -16,6 +16,9 @@ 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";
@@ -33,20 +36,7 @@ export const SessionsPage = () => {
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
healthy: boolean;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
}>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
@@ -125,7 +115,7 @@ export const SessionsPage = () => {
probe_status: "unknown",
last_probe_output: null,
error: "check failed",
},
} as InstanceHealth,
}));
}
}
@@ -153,16 +143,6 @@ export const SessionsPage = () => {
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
[sessions]
);
const recentSessions = useMemo(
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
[sessions]
);
const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId]
@@ -174,43 +154,55 @@ export const SessionsPage = () => {
await loadSessions();
};
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
setLoadingSessionId(sessionId);
const handleStop = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Stopping...");
try {
await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null);
await stopInstance(session.project_id, session.repository_id, session.id);
await loadSessions();
} catch {
setStopConfirmId(null);
// ignore
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
setLoadingSessionId(sessionId);
const handleDelete = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Deleting...");
try {
await deleteInstance(projectId, repoId, sessionId, force);
setDeleteConfirmId(null);
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 !== sessionId));
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(sessions.find((s) => s.id === sessionId) ?? null);
setDirtyDeleteSession(session);
setDirtyDeleteFiles(detail.changed_files);
setDeleteConfirmId(null);
return;
}
}
setDeleteConfirmId(null);
} 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("");
@@ -279,45 +271,18 @@ export const SessionsPage = () => {
{lastSession && (
<div className="last-session-section">
<h2>Last Session</h2>
<div className="card last-session-card">
<div className="last-session-info">
<h3>{lastSession.display_name}</h3>
<p className="muted">
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
</p>
{lastSession.url && (
<p className="session-url">
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
{lastSession.url}
</a>
</p>
)}
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
</div>
<div className="last-session-actions">
{lastSession.url ? (
<a
href={lastSession.url}
target="_blank"
rel="noopener noreferrer"
className="primary-button"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button className="primary-button" onClick={handleResumeLast} type="button">
<Icon name="play" size="sm" />
Resume
</button>
)}
</div>
</div>
<SessionCard
session={lastSession}
onOpen={handleOpen}
onDelete={handleDelete}
isBusy={loadingSessionId === lastSession.id}
tunnelHealth={tunnelHealth[lastSession.id] || null}
/>
</div>
)}
{/* Active Sessions */}
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
{/* Session List */}
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
{loadingSessionId && (
<div className="loading-overlay">
<div className="loading-content">
@@ -326,250 +291,17 @@ export const SessionsPage = () => {
</div>
</div>
)}
<h2>
Active Sessions
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</h2>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => {
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
return (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{session.display_name}</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.created_at && (
<p className="session-meta">
Started: {new Date(session.created_at).toLocaleString()}
</p>
)}
{session.clone_mode && (
<p className="session-meta">
Repo: {session.clone_mode === "clone" ? `clone (${session.branch || "main"})` : "mount"}
</p>
)}
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{session.status === "starting" && (
<span className="status-badge starting">starting...</span>
)}
{session.status === "probing" && (
<span className="status-badge probing">checking...</span>
)}
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<span className="status-badge error">tunnel error</span>
)}
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
)}
{!isTerminalOnly && tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
<div className="probe-output-section">
<button
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
type="button"
>
<Icon name="info" size="sm" />
Probe: {tunnelHealth[session.id].probe_status}
{expandedProbeId === session.id ? " (hide)" : " (show)"}
</button>
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
<pre className="probe-output">
{tunnelHealth[session.id].last_probe_output}
</pre>
)}
</div>
)}
</div>
<div className="session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button small"
onClick={() => handleOpen(session)}
type="button"
>
<Icon name="external" size="sm" />
Open
</button>
)}
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<button
className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)}
type="button"
disabled={recreatingId === session.id}
>
<Icon name="refresh" size="sm" />
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
</button>
)}
{stopConfirmId === session.id ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={() =>
void handleStop(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Stop
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => setStopConfirmId(session.id)}
type="button"
>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
)})}
</div>
)}
<SessionList
sessions={sessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={loadingSessionId}
tunnelHealth={tunnelHealth}
/>
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="recent-sessions-section">
<h2>Recent Sessions</h2>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
</div>
<div className="recent-session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="ghost-button small"
>
Open
</a>
) : (
<button
className="ghost-button small"
onClick={() => handleOpen(session)}
type="button"
>
Open
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
@@ -612,14 +344,7 @@ export const SessionsPage = () => {
</button>
<button
className="danger-button"
onClick={() =>
void handleDelete(
dirtyDeleteSession.id,
dirtyDeleteSession.project_id,
dirtyDeleteSession.repository_id,
true
)
}
onClick={() => void handleForceDelete(dirtyDeleteSession)}
type="button"
>
Force Delete