feat: implement unified session list components

- Add SessionCard component with status indicators, actions, and confirmation dialogs
- Add SessionList component with grouping (active/recent) and filtering
- Refactor dashboard.tsx to use unified components
- Refactor sessions.tsx to use unified components
- Remove duplicated session rendering logic from both pages

Refs: session-list-overhaul tasks 1-4
This commit is contained in:
2026-05-24 12:03:16 +00:00
parent ed15d53493
commit 549d13f469
5 changed files with 471 additions and 438 deletions
+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